diff --git a/src/dev-center/css/style.css b/src/dev-center/css/style.css
index 2edddd9ae..5e68a0c7b 100644
--- a/src/dev-center/css/style.css
+++ b/src/dev-center/css/style.css
@@ -1274,6 +1274,81 @@ dialog{
margin-top: 5px;
}
+.preview-images-help {
+ color: #666;
+ font-size: 13px;
+ margin-top: 5px;
+ margin-bottom: 10px;
+}
+
+.preview-images-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 15px;
+ margin-top: 10px;
+}
+
+.preview-device {
+ border: 1px solid #e5e5e5;
+ border-radius: 6px;
+ padding: 12px;
+ background: #fafafa;
+}
+
+.preview-device-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.preview-device-title {
+ font-weight: 600;
+ color: #333;
+}
+
+.preview-count {
+ font-weight: 400;
+ color: #666;
+ margin-left: 6px;
+ font-size: 13px;
+}
+
+.preview-grid {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ min-height: 90px;
+}
+
+.preview-thumb {
+ width: 120px;
+ height: 90px;
+ border-radius: 5px;
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ position: relative;
+ border: 1px solid #ddd;
+ overflow: hidden;
+}
+
+.remove-preview-image {
+ position: absolute;
+ top: 4px;
+ right: 6px;
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ border-radius: 50%;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 12px;
+}
+
.app-categories {
margin-top: 4px;
display: flex;
@@ -1392,4 +1467,4 @@ dialog{
.tab-btn-separator{
display: none;
-}
\ No newline at end of file
+}
diff --git a/src/dev-center/js/apps.js b/src/dev-center/js/apps.js
index b7aeec903..3761f0060 100644
--- a/src/dev-center/js/apps.js
+++ b/src/dev-center/js/apps.js
@@ -25,6 +25,12 @@ const APP_CATEGORIES = [
{ 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) {
@@ -381,6 +387,7 @@ function generate_edit_app_section (app) {
Remove icon
${generateSocialImageSection(app)}
+ ${generatePreviewImagesSection()}
@@ -504,6 +511,7 @@ function trackOriginalValues () {
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(),
@@ -534,6 +542,10 @@ function hasChanges () {
return true;
}
+ if ( serializePreviewState(getPreviewImagesState()) !== serializePreviewState(originalValues.previewImages) ) {
+ return true;
+ }
+
// if any of the fields have changed
return (
$('#edit-app-title').val() !== originalValues.title ||
@@ -624,6 +636,8 @@ function resetToOriginalValues () {
$('#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') {
@@ -639,6 +653,7 @@ async function edit_app_section (cur_app_name, tab = 'deploy') {
// 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
@@ -1183,6 +1198,9 @@ $(document).on('click', '.edit-app-save-btn', async function (e) {
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,
@@ -1207,10 +1225,14 @@ $(document).on('click', '.edit-app-save-btn', async function (e) {
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
@@ -2485,12 +2507,19 @@ window.initializeAssetsDirectory = async () => {
const existingURL = await puter.kv.get('assets_url');
if ( ! existingURL ) {
// Create assets directory
- const assetsDir = await puter.fs.mkdir(`/${auth_username}/AppData/${dev_center_uid}/assets`,
- { overwrite: false });
+ 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, assetsDir.path);
+ const route = await puter.hosting.create(hostname, assetsPath);
// Store the URL
await puter.kv.set('assets_url', `https://${hostname}.puter.site`);
@@ -2500,6 +2529,17 @@ window.initializeAssetsDirectory = async () => {
}
};
+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 `
@@ -2511,6 +2551,24 @@ window.generateSocialImageSection = (app) => {
`;
};
+window.generatePreviewImagesSection = () => {
+ return `
+
Preview Images
+ Upload up to 10 images for each device type to showcase your app listing.
+
+ ${PREVIEW_DEVICES.map((device) => `
+
+ `).join('')}
+
+ `;
+};
+
$(document).on('click', '#edit-app-social-image', async function (e) {
const res = await puter.ui.showOpenFilePicker({
accept: 'image/*',
@@ -2546,12 +2604,162 @@ $(document).on('click', '#edit-app-social-image-delete', async function (e) {
$('#edit-app-social-image-delete').hide();
});
+function getDefaultPreviewImagesState () {
+ return {
+ desktop: [],
+ tablet: [],
+ mobile: [],
+ };
+}
+
+function createPreviewThumbElement (item) {
+ const previewItem = item || {};
+ const $thumb = $('');
+ 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('×');
+ 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 puter.kv.get('assets_url');
- if ( ! assets_url ) throw new Error('Assets URL not found');
+ const assets_url = await ensureAssetsURL();
// Convert base64 to blob
const base64Response = await fetch(socialImageData);
@@ -2572,6 +2780,42 @@ window.handleSocialImageUpload = async (app_name, socialImageData) => {
}
};
+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);
@@ -2827,4 +3071,4 @@ async function attempt_delete_app (app_name, app_title, app_uid) {
}
-export default init_apps;
\ No newline at end of file
+export default init_apps;
diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css
index f50a2d303..b411bfa97 100644
--- a/src/gui/src/css/style.css
+++ b/src/gui/src/css/style.css
@@ -1345,7 +1345,7 @@ span.header-sort-icon img {
.window-sidebar-item-dragging {
background-color: #f5f5f5 !important;
opacity: 0.8;
- cursor: grabbing;
+ cursor: grabbing !important;
}
.ui-sortable-helper {