mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-05 03:18:30 +00:00
Add preview images upload for app listings
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+250
-6
@@ -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) {
|
||||
<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>
|
||||
|
||||
@@ -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 `
|
||||
<label for="edit-app-social-image">Social Graph Image (1200×630 strongly recommended)</label>
|
||||
@@ -2511,6 +2551,24 @@ window.generateSocialImageSection = (app) => {
|
||||
`;
|
||||
};
|
||||
|
||||
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/*',
|
||||
@@ -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 = $('<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 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;
|
||||
export default init_apps;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user