Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 76ad6cbc2e Keep dropped desktop icons fully on screen, and fill the phone popover
A desktop icon drop is accepted only where the whole item fits above the
bottom edge. Phone popovers use the full viewport height.
2026-11-11 18:38:36 -07:00
Nariman Jelveh 9251bfa30b Keep desktop backdrops above ordinary windows
A backdrop's z-index used to take the taskbar's as a floor, which kept
modal dialogs on /desktop above every ordinary window, including ones
focused after the dialog opened. Keep that floor on the desktop; the
dashboard and fullpage mode stack as before.

Also drops commented-out geometry that referenced window.taskbar_height,
and a dashboard comment about the removed fullpage minimize rule.
2026-11-11 18:15:40 -07:00
Nariman Jelveh bf99c3c680 Remove the desktop taskbar
The desktop no longer has a taskbar. Windows on /desktop can't be
minimized, since nothing could bring them back; the dashboard still
minimizes into app tiles. The desktop and maximized windows use the
full page height.

Backend: POST /update-taskbar-items is removed, and /whoami and the
login response no longer include taskbar_items. The user table's
taskbar_items column stays until a migration drops it.

puter.ui.createWindow() still accepts show_in_taskbar, which has no
effect and is marked deprecated.
2026-11-11 18:02:40 -07:00
78 changed files with 1681 additions and 361 deletions

No files matched your search

@@ -79,7 +79,6 @@ interface CapturedResponse {
statusCode: number;
body: unknown;
contentType?: string;
location?: string;
}
const makeReq = (init: {
@@ -88,7 +87,6 @@ const makeReq = (init: {
actor?: Actor;
hostname?: string;
protocol?: string;
originalUrl?: string;
}): Request => {
return {
body: {},
@@ -96,7 +94,6 @@ const makeReq = (init: {
headers: {},
params: init.params ?? {},
path: init.path ?? '/',
originalUrl: init.originalUrl ?? init.path ?? '/',
hostname: init.hostname ?? 'test.local',
protocol: init.protocol ?? 'http',
actor: init.actor,
@@ -134,11 +131,6 @@ const makeRes = () => {
captured.contentType = value;
return res;
}),
redirect: vi.fn((location: string) => {
captured.statusCode = 302;
captured.location = location;
return res;
}),
};
return { res: res as unknown as Response, captured };
};
@@ -222,56 +214,23 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('serves the shell on /dashboard and /action', async () => {
for (const path of ['/dashboard', '/dashboard/', '/action/*splat']) {
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(typeof captured.body).toBe('string');
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
}
});
it('redirects /desktop to the root, keeping the query', async () => {
for (const path of ['/desktop', '/desktop/']) {
const { res, captured } = makeRes();
await callRoute(
'get',
path,
makeReq({ path, originalUrl: `${path}?ref=abc&x=1` }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/?ref=abc&x=1');
expect(captured.body).toBeUndefined();
}
const { res, captured } = makeRes();
await callRoute('get', '/desktop', makeReq({ path: '/desktop' }), res);
expect(captured.location).toBe('/');
});
it('redirects /settings to the dashboard Home tab', async () => {
for (const path of ['/settings', '/settings/*splat']) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/dashboard#home');
expect(captured.body).toBeUndefined();
}
});
it('redirects /@:username to the dashboard', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/@:username',
makeReq({ path: '/@alice', params: { username: 'alice' } }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/');
expect(captured.body).toBeUndefined();
});
});
// ── /app/:name ──────────────────────────────────────────────────────
@@ -401,21 +360,47 @@ describe('HomepageController GET /app/:name', () => {
expect(html).not.toContain('owner_user_id');
});
it('redirects /desktop/app/:name to /app/:name, keeping the query', async () => {
it('serves the same app shell under /desktop/app/:name', async () => {
const { userId } = await makeUser();
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
await server.stores.app.create(
{
name,
title: 'Desktop App',
description: 'opens on the desktop instead of the dashboard',
index_url: `https://example.com/${name}/`,
approved_for_listing: 1,
},
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
expect(html).toMatch(/<!DOCTYPE html>/i);
expect(html).toContain('Desktop App');
});
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'editor' },
path: '/desktop/app/editor',
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt&x=1',
params: { name: 'no-such-app' },
path: '/desktop/app/no-such-app',
}),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt&x=1');
expect(captured.body).toBeUndefined();
expect(captured.statusCode).toBe(404);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
@@ -449,17 +434,19 @@ describe('HomepageController GET /app/:name', () => {
// -- app landing rate limit --
describe('HomepageController app landing rate limit', () => {
it('rate-limits /app/:name per network', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/app/:name',
);
if (!route) throw new Error('No GET /app/:name route');
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
for (const path of ['/app/:name', '/desktop/app/:name']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
);
if (!route) throw new Error(`No GET ${path} route`);
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
}
});
});
@@ -263,42 +263,23 @@ export class HomepageController extends PuterController {
});
}
// Old settings links land on the dashboard's Home tab.
router.get('/settings', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/settings/*splat', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
const queryOf = (req: express.Request) => {
const separator = req.originalUrl.indexOf('?');
return separator === -1 ? '' : req.originalUrl.slice(separator);
};
// Old desktop links land on the dashboard, query intact.
router.get('/desktop', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/desktop/', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/desktop', {}, (req, res) => sendShell(req, res));
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
// Old links to a user's public folder land on the dashboard.
router.get('/@:username', {}, (_req, res) => res.redirect('/'));
// Old desktop app links open the app in the dashboard, query intact.
router.get('/desktop/app/:name', {}, (req, res) => {
const name = encodeURIComponent(String(req.params.name ?? ''));
res.redirect(`/app/${name}${queryOf(req)}`);
});
router.get('/@:username', {}, (req, res) => sendShell(req, res));
// -- /app/:name - app metadata baked into the shell ----------
// `/desktop/app/:name` is the same landing booted on the desktop
// instead of the dashboard; the GUI strips the prefix and treats the
// rest of the path identically, so both render the same shell.
const sendAppShell = async (
req: express.Request,
@@ -353,6 +334,11 @@ export class HomepageController extends PuterController {
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
router.get(
'/desktop/app/:name',
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
// -- /show/* - launch explorer with the requested file path --
@@ -372,6 +372,26 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
);
});
it('bakes a whitelisted desktop app landing (/desktop/app/<name>) into the state redirect_uri', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({
params: { provider: 'custom' },
query: { return_to: '/desktop/app/my-App_2' },
}),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(
`${TEST_ORIGIN}/desktop/app/my-App_2`,
);
});
it('ignores a non-whitelisted return_to', async () => {
const bad_values = [
'/app/evil/extra',
@@ -380,8 +400,6 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
'//evil.test',
'/settings',
`/app/${'a'.repeat(101)}`,
'/desktop',
'/desktop/app/my-App_2',
'/desktop/app/evil/extra',
'/desktop/app/',
'/dashboard/app/name',
@@ -458,11 +476,11 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
sharedPath('b.txt'),
sharedPath('a.txt'),
],
'/dashboard',
'/desktop',
),
);
const url = new URL(uri);
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/dashboard`);
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/desktop`);
expect(url.searchParams.getAll('shared')).toEqual([
sharedPath('a.txt'),
sharedPath('b.txt'),
@@ -102,14 +102,16 @@ function signupOptionsFromState(
}
// GUI pages an OIDC flow may return to: the root (where a share email lands),
// /dashboard, and direct app landings (/app/<name>, mirroring
// APP_NAME_REGEX in AppDriver). Strict whitelist — never a client-supplied URL
// (no open redirect).
// /desktop, /dashboard, and direct app landings (/app/<name> and its
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
// AppDriver). Strict whitelist — never a client-supplied URL (no open
// redirect).
function isWhitelistedReturnPath(path: string): boolean {
return (
path === '/' ||
path === '/desktop' ||
path === '/dashboard' ||
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
/^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
);
}
@@ -436,8 +438,9 @@ export class OIDCController extends PuterController {
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
// Optional GUI return path so login started from /dashboard,
// an /app/<name> landing, or a share link lands back there.
// Optional GUI return path so login started from /desktop,
// /dashboard, an /app/<name> landing, or a share link lands
// back there.
const rawReturnTo = Array.isArray(req.query.return_to)
? req.query.return_to[0]
: req.query.return_to;
@@ -314,8 +314,8 @@ describe('OIDC account second-factor login', () => {
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
await account(false);
const response = await callback('login', { return_to: '/dashboard' });
expect(response.headers.get('location')).toBe(`${env.origin}/dashboard`);
const response = await callback('login', { return_to: '/desktop' });
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
expect(
response.headers.get('set-cookie')?.includes('puter_auth_token='),
).toBe(true);
+1 -1
View File
@@ -418,7 +418,7 @@ For long-lived mounts, sign in with a `-token` username and an API token as the
| ------------------------------------------------------------------ | ------------------------------- | ----------- |
| `nameAvailable` | 60/min | |
| App icon | 12,000/min | Per network |
| App landing page (`/app/<name>`) | 600/min | Per network |
| App landing page (`/app/<name>`, `/desktop/app/<name>`) | 600/min | Per network |
| Names per batch app lookup (`GET /apps/:names`, `POST /query/app`) | 200, up to 200 characters each | |
| `metadata` | 16 KiB, measured as JSON | |
| `filetypeAssociations` | 200 entries, 60 characters each | |
+1 -1
View File
@@ -144,7 +144,7 @@ try {
startServer(1);
app.get(['/', '/app/*splat', '/action/*splat', '/dashboard'], (req, res) => {
app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/desktop/app/*splat', '/dashboard'], (req, res) => {
res.send(generateDevHtml({
env: env,
api_origin: apiOrigin,
+3 -2
View File
@@ -424,14 +424,15 @@ export const authHeadHtml = (flow, title, { openerNotice = true } = {}) => {
};
/**
* On a cover page, the logo is a way out, to the root dashboard.
* On a cover page, the logo is a way out: desktop users stay on the desktop,
* everyone else goes to the root dashboard.
*
* @param {AuthFlow} flow
* @returns {string} Empty where the logo leads nowhere.
*/
const logoHref = (flow) => {
if ( ! flow.page || window.embedded_in_popup ) return '';
return '/';
return window.location.pathname === '/desktop' ? '/desktop' : '/';
};
/**
+711 -12
View File
@@ -19,16 +19,23 @@
import path from '../lib/path.js';
import UIContextMenu from './UIContextMenu.js';
import UIItem from './UIItem.js';
import UIAlert from './UIAlert.js';
import UIWindow from './UIWindow.js';
import UIWindowSaveAccount from './UIWindowSaveAccount.js';
import UIAuthLogin from './Auth/UIAuthLogin.js';
import UIWindowProgress from './UIWindowProgress.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
import truncate_filename from '../helpers/truncateFilename.js';
import UINotification from './UINotification.js';
import launch_app from '../helpers/launchApp.js';
import { urlFileLaunchOptions } from '../helpers/confirmUrlFileAccess.js';
import item_icon from '../helpers/itemIcon.js';
import { SHARED_PATH_PARAM, clear_shared_param } from '../helpers/parseSharedPath.js';
import resolve_shared_item from '../helpers/resolveSharedItem.js';
import {
answerNotificationReceipts,
@@ -55,7 +62,7 @@ import apply_item_added_to_containers from '../helpers/applyItemAddedToContainer
import parse_item_metadata from '../helpers/parseItemMetadata.js';
import { resolve_default_app } from '../helpers/defaultApp.js';
async function UIDesktop () {
async function UIDesktop (options) {
// start a transaction if we're not in embedded or fullpage mode
let transaction;
if ( !window.is_embedded && !window.is_fullpage_mode ) {
@@ -65,6 +72,58 @@ async function UIDesktop () {
let h = '';
// Set up the desktop channel for communication between different tabs in the same browser
window.channel = new BroadcastChannel('puter-desktop-channel');
channel.onmessage = function (e) {
};
// Initialize desktop icons visibility preference - move this earlier in the initialization
// Add this near the very beginning of the UIDesktop function
window.desktop_icons_hidden = false; // Set default value immediately
// Give Camera and Recorder write permissions to Desktop
puter.kv.get('has_set_default_app_user_permissions').then(async (user_permissions) => {
if ( ! user_permissions ) {
// Camera
try {
await fetch(`${window.api_origin }/auth/grant-user-app`, {
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ window.auth_token}`,
},
'body': JSON.stringify({
app_uid: 'app-5584fbf7-ed69-41fc-99cd-85da21b1ef51',
permission: `fs:${html_encode(window.desktop_path)}:write`,
}),
'method': 'POST',
});
} catch ( err ) {
console.error(err);
}
// Recorder
try {
await fetch(`${window.api_origin }/auth/grant-user-app`, {
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ window.auth_token}`,
},
'body': JSON.stringify({
app_uid: 'app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1',
permission: `fs:${html_encode(window.desktop_path)}:write`,
}),
'method': 'POST',
});
} catch ( err ) {
console.error(err);
}
// Set flag to true
await puter.kv.set('has_set_default_app_user_permissions', true);
}
// Awaited above so a failed write reaches this: it leaves the flag unset
// and the whole thing is retried next boot, which is what we want.
}).catch(err => console.warn('Could not apply default app permissions:', err));
// connect socket.
window.socket = io(`${window.gui_origin }/`, {
auth: {
@@ -654,8 +713,16 @@ async function UIDesktop () {
h += '<div class="window-container"></div>';
// The surface windows open on. It holds no items.
h += '<div class="desktop disable-user-select"></div>';
// Desktop
// If desktop is not in fullpage/embedded mode, we hide it until files and directories are loaded and then fade in the UI
// This gives a calm and smooth experience for the user
h += `<div class="desktop item-container disable-user-select"
data-uid="${options.desktop_fsentry?.uid ?? ''}"
data-sort_by="${!options.desktop_fsentry?.sort_by ? 'name' : options.desktop_fsentry.sort_by}"
data-sort_order="${!options.desktop_fsentry?.sort_order ? 'asc' : options.desktop_fsentry.sort_order}"
data-path="${html_encode(window.desktop_path)}"
>`;
h += '</div>';
// Get window sidebar width
puter.kv.get('window_sidebar_width').catch(() => null).then(async (val) => {
@@ -691,22 +758,409 @@ async function UIDesktop () {
// Append to <body>
$('body').append(h);
// Initialize the preference early
puter.kv.get('desktop_icons_hidden').then(async (val) => {
window.desktop_icons_hidden = (val === 'true' || val === true);
// Apply the setting immediately if needed
if ( window.desktop_icons_hidden ) {
hideDesktopIcons();
}
}).catch(err => console.warn('Could not load desktop_icons_hidden:', err));
const el_desktop = document.querySelector('.desktop');
window.active_element = el_desktop;
window.active_item_container = el_desktop;
// The desktop has no menu of its own, and the browser's doesn't belong here.
$(el_desktop).on('contextmenu', function (event) {
if ( event.target === el_desktop ) event.preventDefault();
// --------------------------------------------------------
// Dragster
// Allow dragging of local files onto desktop.
// --------------------------------------------------------
$(el_desktop).dragster({
enter: function (dragsterEvent, event) {
$('.context-menu').remove();
},
leave: function (dragsterEvent, event) {
},
drop: async function (dragsterEvent, event) {
const e = event.originalEvent;
// no drop on item
if ( $(event.target).hasClass('item') || $(event.target).parent('.item').length > 0 )
{
return false;
}
// recursively create directories and upload files
if ( e.dataTransfer?.items?.length > 0 ) {
window.upload_items(e.dataTransfer.items, window.desktop_path);
}
e.stopPropagation();
e.preventDefault();
return false;
},
});
if ( !window.is_embedded && !window.is_fullpage_mode ) {
transaction.end();
// --------------------------------------------------------
// Droppable
// --------------------------------------------------------
$(el_desktop).droppable({
accept: '.item',
tolerance: 'intersect',
drop: function (event, ui) {
// Check if item was actually dropped on desktop and not a window
if ( window.mouseover_window !== undefined )
{
return;
}
// warm the home directory listing for the first Explorer window
// Can't drop anything but UIItems on desktop
if ( ! $(ui.draggable).hasClass('item') )
{
return;
}
// Don't move an item to its current directory
if ( path.dirname($(ui.draggable).attr('data-path')) === window.desktop_path && !event.ctrlKey )
{
return;
}
// If ctrl is pressed and source is Trashed, cancel whole operation
if ( event.ctrlKey && path.dirname($(ui.draggable).attr('data-path')) === window.trash_path )
{
return;
}
// Unselect previously selected items
$(el_desktop).children('.item-selected').removeClass('item-selected');
const items_to_move = [];
// first item
items_to_move.push(ui.draggable);
// all subsequent items
const cloned_items = document.getElementsByClassName('item-selected-clone');
for ( let i = 0; i < cloned_items.length; i++ ) {
const source_item = document.getElementById(`item-${ $(cloned_items[i]).attr('data-id')}`);
if ( source_item !== null )
{
items_to_move.push(source_item);
}
}
// if ctrl key is down, copy items
if ( event.ctrlKey ) {
// unless source is Trash
if ( path.dirname($(ui.draggable).attr('data-path')) === window.trash_path )
{
return;
}
window.copy_items(items_to_move, window.desktop_path);
}
// otherwise, move items
else {
window.move_items(items_to_move, window.desktop_path);
}
},
});
//--------------------------------------------------
// ContextMenu
//--------------------------------------------------
$(el_desktop).bind('contextmenu taphold', function (event) {
// dismiss taphold on regular devices
if ( event.type === 'taphold' && !isMobile.phone && !isMobile.tablet )
{
return;
}
const $target = $(event.target);
// elements that should retain native ctxmenu
if ( $target.is('input') || $target.is('textarea') )
{
return true;
}
// custom ctxmenu for all other elements
if ( event.target === el_desktop ) {
event.preventDefault();
UIContextMenu({
position: event.type === 'taphold' ? undefined : { left: event.pageX, top: event.pageY },
items: [
// -------------------------------------------
// Sort by
// -------------------------------------------
{
html: i18n('sort_by'),
items: [
{
html: i18n('auto_arrange'),
icon: window.is_auto_arrange_enabled ? '✓' : '',
onClick: async function () {
window.is_auto_arrange_enabled = !window.is_auto_arrange_enabled;
window.store_auto_arrange_preference(window.is_auto_arrange_enabled);
if ( window.is_auto_arrange_enabled ) {
window.sort_items(el_desktop, $(el_desktop).attr('data-sort_by'), $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, $(el_desktop).attr('data-sort_by'), $(el_desktop).attr('data-sort_order'));
window.clear_desktop_item_positions(el_desktop);
} else {
window.set_desktop_item_positions(el_desktop);
}
},
},
// -------------------------------------------
// -
// -------------------------------------------
'-',
{
html: i18n('name'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'name' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'name', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'name', $(el_desktop).attr('data-sort_order'));
},
},
{
html: i18n('date_modified'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'modified' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'modified', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'modified', $(el_desktop).attr('data-sort_order'));
},
},
{
html: i18n('type'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'type' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'type', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'type', $(el_desktop).attr('data-sort_order'));
},
},
{
html: i18n('size'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_by') === 'size' ? '✓' : '',
onClick: async function () {
window.sort_items(el_desktop, 'size', $(el_desktop).attr('data-sort_order'));
window.set_sort_by(options.desktop_fsentry.uid, 'size', $(el_desktop).attr('data-sort_order'));
},
},
// -------------------------------------------
// -
// -------------------------------------------
'-',
{
html: i18n('ascending'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_order') === 'asc' ? '✓' : '',
onClick: async function () {
const sort_by = $(el_desktop).attr('data-sort_by');
window.sort_items(el_desktop, sort_by, 'asc');
window.set_sort_by(options.desktop_fsentry.uid, sort_by, 'asc');
},
},
{
html: i18n('descending'),
disabled: !window.is_auto_arrange_enabled,
icon: $(el_desktop).attr('data-sort_order') === 'desc' ? '✓' : '',
onClick: async function () {
const sort_by = $(el_desktop).attr('data-sort_by');
window.sort_items(el_desktop, sort_by, 'desc');
window.set_sort_by(options.desktop_fsentry.uid, sort_by, 'desc');
},
},
],
},
// -------------------------------------------
// Refresh
// -------------------------------------------
{
html: i18n('refresh'),
onClick: function () {
refresh_item_container(el_desktop, { consistency: 'strong' });
},
},
// -------------------------------------------
// Show/Hide hidden files
// -------------------------------------------
{
html: i18n('show_hidden'),
icon: window.user_preferences.show_hidden_files ? '✓' : '',
onClick: function () {
window.mutate_user_preferences({
show_hidden_files: !window.user_preferences.show_hidden_files,
});
window.show_or_hide_files(document.querySelectorAll('.item-container'));
},
},
// -------------------------------------------
// Hide Desktop Icons
// -------------------------------------------
{
html: window.desktop_icons_hidden ? i18n('Show desktop icons') : i18n('Hide desktop icons'),
onClick: function () {
toggleDesktopIcons();
},
},
// -------------------------------------------
// -
// -------------------------------------------
'-',
// -------------------------------------------
// New File
// -------------------------------------------
new_context_menu_item(window.desktop_path, el_desktop),
// -------------------------------------------
// -
// -------------------------------------------
'-',
// -------------------------------------------
// Paste
// -------------------------------------------
{
html: i18n('paste'),
disabled: window.clipboard.length > 0 ? false : true,
onClick: function () {
if ( window.clipboard_op === 'copy' )
{
window.copy_clipboard_items(window.desktop_path, el_desktop);
}
else if ( window.clipboard_op === 'move' )
{
window.move_clipboard_items(el_desktop);
}
},
},
// -------------------------------------------
// Undo
// -------------------------------------------
{
html: i18n('undo'),
disabled: window.actions_history.length > 0 ? false : true,
onClick: function () {
window.undo_last_action();
},
},
// -------------------------------------------
// Upload Here
// -------------------------------------------
{
html: i18n('upload_here'),
onClick: function () {
window.init_upload_using_dialog(el_desktop);
},
},
],
});
}
});
//-------------------------------------------
// Desktop Files/Folders
// we don't need to get the desktop items if we're in embedded or fullpage mode
// because the items aren't visible anyway and we don't need to waste bandwidth/server resources
//-------------------------------------------
if ( !window.is_embedded && !window.is_fullpage_mode ) {
refresh_item_container(el_desktop, {
fadeInItems: true,
onComplete: () => {
// End transaction when desktop is fully ready for user interaction
transaction.end();
},
});
// perform readdirs for caching purposes
// home directory
puter.fs.readdir({ path: window.home_path, consistency: 'strong' });
}
// -------------------------------------------
// Selectable
// Only for desktop
// -------------------------------------------
if ( !isMobile.phone && !isMobile.tablet ) {
let selected_ctrl_items = [];
const selection = new SelectionArea({
selectionContainerClass: '.selection-area-container',
container: '.desktop',
selectables: ['.desktop.item-container > .item'],
startareas: ['.desktop'],
boundaries: ['.desktop'],
behaviour: {
overlap: 'drop',
intersect: 'touch',
startThreshold: 10,
scrolling: {
speedDivider: 10,
manualSpeed: 750,
startScrollMargins: { x: 0, y: 0 },
},
},
features: {
touch: true,
range: true,
singleTap: {
allow: true,
intersect: 'native',
},
},
});
selection.on('beforestart', ({ event }) => {
selected_ctrl_items = [];
// Returning false prevents a selection
return $(event.target).hasClass('item-container');
})
.on('beforedrag', evt => {
})
.on('start', ({ store, event }) => {
if ( !event.ctrlKey && !event.metaKey ) {
for ( const el of store.stored ) {
el.classList.remove('item-selected');
}
selection.clearSelection();
}
// mark desktop as selectable active
$('.desktop').addClass('desktop-selectable-active');
})
.on('move', ({ store: { changed: { added, removed } }, event }) => {
window.desktop_selectable_is_active = true;
for ( const el of added ) {
// if ctrl or meta key is pressed and the item is already selected, then unselect it
if ( (event.ctrlKey || event.metaKey) && $(el).hasClass('item-selected') ) {
el.classList.remove('item-selected');
selected_ctrl_items.push(el);
}
// otherwise select it
else {
el.classList.add('item-selected');
}
}
for ( const el of removed ) {
el.classList.remove('item-selected');
// in case this item was selected by ctrl+click before, then reselect it again
if ( selected_ctrl_items.includes(el) )
{
$(el).not('.item-disabled').addClass('item-selected');
}
}
})
.on('stop', evt => {
window.desktop_selectable_is_active = false;
$('.desktop').removeClass('desktop-selectable-active');
});
}
// notification container
$('body').append(`<div class="notification-container"><div class="notifications-close-all">${i18n('close_all')}</div></div>`);
@@ -746,10 +1200,41 @@ async function UIDesktop () {
}
}
}
//--------------------------------------------------------------------------------------
// /settings redirects to /dashboard
//--------------------------------------------------------------------------------------
else if ( window.url_paths[0]?.toLocaleLowerCase() === 'settings' ) {
window.open('/dashboard/#home', '_blank');
}
// ---------------------------------------------
// Run apps from insta-login URL
// ---------------------------------------------
if ( window.url_query_params.has('app') ) {
let url_app_name = window.url_query_params.get('app');
if ( url_app_name === 'explorer' ) {
let predefined_path = window.home_path;
if ( window.url_query_params.has('path') )
{
predefined_path = window.url_query_params.get('path');
}
// launch explorer
UIWindow({
path: predefined_path,
title: path.basename(predefined_path),
icon: await item_icon({ is_dir: true, path: predefined_path }),
// todo
// uid: $(el_item).attr('data-uid'),
is_dir: true,
// todo
// sort_by: $(el_item).attr('data-sort_by'),
app: 'explorer',
});
}
}
// ---------------------------------------------
// load from direct app URLs: /app/app-name
// ---------------------------------------------
if ( window.app_launched_from_url ) {
else if ( window.app_launched_from_url ) {
if ( ! window.url_query_params.has('c') ) {
let posargs = undefined;
if ( window.app_query_params && window.app_query_params.posargs ) {
@@ -785,10 +1270,10 @@ async function UIDesktop () {
return;
}
// disable pointer-events for all app iframes, this is to make sure selectable works
$('.window-app-iframe').css('pointer-events', 'none');
$('.window').find('.item-selected').addClass('item-blurred');
// Keyboard shortcuts act on the active container, and a click here leaves every window's.
window.active_item_container = null;
$('.desktop').find('.item-blurred').removeClass('item-blurred');
});
$(el_desktop).on('click', function (e) {
@@ -798,6 +1283,43 @@ async function UIDesktop () {
$('.window-menubar-global').hide();
});
//--------------------------------------------------------------------------------------
// Trying to view a user's public folder?
// i.e. https://puter.com/@<username>
//--------------------------------------------------------------------------------------
const url_paths = window.location.pathname.split('/').filter(element => element);
if ( window.url_paths[0]?.startsWith('@') ) {
const username = window.url_paths[0].substring(1);
let item_path = `/${ username }/Public`;
if ( window.url_paths.length > 1 ) {
item_path += `/${ window.url_paths.slice(1).join('/')}`;
}
// GUARD: avoid invalid user directories
{
if ( ! username.match(/^[a-z0-9_]+$/i) ) {
UIAlert({
message: i18n('error_invalid_username'),
});
return;
}
}
let stat;
try {
stat = await puter.fs.stat({ path: item_path, consistency: 'eventual' });
} catch ( e ) {
window.history.replaceState(null, document.title, '/desktop');
UIAlert({
message: i18n('error_user_or_path_not_found'),
type: 'error',
});
return;
}
await open_path_target(item_path, stat);
}
/**
* Open a path as double-clicking would: the associated app for a file, an
* explorer window for a directory.
@@ -852,6 +1374,7 @@ async function UIDesktop () {
file_path: item_path,
app_obj: suggested_apps[0],
window_title: path.basename(item_path),
maximized: options.maximized,
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
});
@@ -889,6 +1412,157 @@ async function UIDesktop () {
await open_path_target(stat.path ?? shared_path, stat);
return true;
}
/**
* Act on a share link. A share to a person only ever reaches a real
* account, so for a temporary session the item is tried first — an item
* open to anyone with the link resolves for it — and only a miss asks
* for a sign-in, rather than burning the link on a "not found" as
* somebody who can't see it. The link stays in the address bar across
* the prompt because login reloads on success, which brings it back for
* the account that can actually open it.
*/
async function handle_shared_link (shared_path) {
if ( window.user?.is_temp ) {
const stat = await resolve_shared_item(puter.fs, shared_path);
if ( stat ) {
clear_shared_param();
await open_path_target(stat.path ?? shared_path, stat);
return;
}
await UIAuthLogin({
reload_on_success: true,
page: true,
});
// Dismissed without signing in: drop the link rather than loop.
if ( window.user?.is_temp ) clear_shared_param();
return;
}
clear_shared_param();
await open_shared_item(shared_path);
}
//--------------------------------------------------------------------------------------
// Opening an item someone shared, on the desktop
// i.e. https://puter.com/desktop?shared=%2F<owner>%2F<uuid>%2F<name>
// (the same link at the root lands in the dashboard's Shared view instead)
//--------------------------------------------------------------------------------------
if ( window.url_query_params.has(SHARED_PATH_PARAM) ) {
await handle_shared_link(window.url_query_params.get(SHARED_PATH_PARAM));
}
//--------------------------------------------------------------------------------------
// Direct download link
// i.e. https://puter.com/?download=<file_url> or https://puter.com/desktop?download=<file_url>
//--------------------------------------------------------------------------------------
const is_bare_desktop_path = window.url_paths.length === 0
|| (window.url_paths.length === 1 && window.url_paths[0]?.toLocaleLowerCase() === 'desktop');
if ( is_bare_desktop_path && window.url_query_params.has('download') ) {
const url = window.url_query_params.get('download');
let file_name = url.split('/').pop().split('?')[0];
let response = await UIAlert({
message: i18n('confirm_download_file_to_desktop', file_name),
type: 'confirm',
buttons: [
{ label: i18n('alert_yes'), value: true, type: 'primary' },
{ label: i18n('alert_no'), value: false, type: 'secondary' },
],
});
if ( ! response )
{
return;
}
let cancelled = false;
let upload_xhr = null;
const abort_controller = new AbortController();
// create progressbar dialog
let progwin = await UIWindowProgress({
title: i18n('downloading'),
icon: window.icons['app-icon-uploader.svg'],
operation_id: window.uuidv4(),
show_progress: true,
on_cancel: () => {
cancelled = true;
abort_controller.abort();
if ( upload_xhr ) {
upload_xhr.abort();
}
},
});
progwin?.set_status(i18n('downloading_file', file_name));
(async () => {
try {
// download the file
const response = await puter.net.fetch(url, {
signal: abort_controller.signal,
});
const total = Number(response.headers.get('content-length'));
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while ( true ) {
const { done, value } = await reader.read();
if ( done || cancelled ) break;
if ( value ) {
// store the chunk
chunks.push(value);
received += value.length;
// calculate progress
const progress = Number.isFinite(total) && total > 0
? received / total
: 0;
// update progressbar
progwin?.set_progress(Math.floor(progress * 100));
}
}
if ( cancelled ) {
progwin?.close();
return;
}
// combine chunks into a blob
let blob = new Blob(chunks, {
type: response.headers.get('content-type') ?? 'application/octet-stream',
});
// reset progressbar
progwin?.set_progress(0);
progwin?.set_status(i18n('uploading_file', file_name));
// upload to user's desktop
await puter.fs.write(`~/Desktop/${file_name}`, blob, {
dedupeName: true,
progress: (_, percent) => {
// update progressbar
progwin?.set_progress(percent);
},
init: (_, xhr) => {
upload_xhr = xhr;
},
});
} catch (e) {
// alert the user if there's a genuine error
if ( !cancelled && e.name !== 'AbortError' ) {
await UIAlert({
message: `${i18n('error_download_failed') }: ${ e.message}`,
type: 'error',
});
}
}
// close progress window
progwin?.close();
})();
}
}
window.enter_fullpage_mode = (el_window) => {
@@ -905,6 +1579,7 @@ window.enter_fullpage_mode = (el_window) => {
window.exit_fullpage_mode = (el_window) => {
$('body').removeClass('fullpage-mode');
refresh_item_container($('.desktop.item-container'), { fadeInItems: true });
$(el_window).removeAttr('data-is_fullpage');
if ( el_window ) {
window.reset_window_size_and_position(el_window);
@@ -922,4 +1597,28 @@ window.reset_window_size_and_position = (el_window) => {
});
};
// Modify the hide/show functions to use CSS rules that will apply to all icons, including future ones
window.hideDesktopIcons = function () {
$('.desktop.item-container').addClass('desktop-icons-hidden');
};
window.showDesktopIcons = function () {
$('.desktop.item-container').removeClass('desktop-icons-hidden');
};
// Add this function to the global scope
window.toggleDesktopIcons = function () {
window.desktop_icons_hidden = !window.desktop_icons_hidden;
if ( window.desktop_icons_hidden ) {
hideDesktopIcons();
} else {
showDesktopIcons();
}
// Save preference
puter.kv.set('desktop_icons_hidden', window.desktop_icons_hidden.toString())
.catch(err => console.warn('Could not save desktop_icons_hidden:', err));
};
export default UIDesktop;
+50 -1
View File
@@ -25,6 +25,7 @@ import path from '../lib/path.js';
import truncate_filename from '../helpers/truncateFilename.js';
import launch_app from '../helpers/launchApp.js';
import open_item from '../helpers/openItem.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js';
import { is_owned_by_me } from '../helpers/pathOwner.js';
import { can_rename, can_restructure, invalidate_shared_roots } from '../helpers/sharedAccess.js';
@@ -234,6 +235,17 @@ async function UIItem (options) {
window.update_explorer_footer_item_count(el_window);
}
// manual positioning
if ( !window.is_auto_arrange_enabled &&
options.position &&
// item is on the desktop (must be desktop itself and not a window, hence the '.desktop' class check)
$(el_item).closest('.item-container.desktop').attr('data-path') === window.desktop_path
) {
el_item.style.position = 'absolute';
el_item.style.left = `${options.position.left }px`;
el_item.style.top = `${options.position.top }px`;
}
// --------------------------------------------------------
// Dragster
// allow dragging of local files on this window, if it's is_dir
@@ -398,6 +410,21 @@ async function UIItem (options) {
}
},
stop: function (event, ui) {
// Allow rearranging only if item is on desktop, not trash container, auto arrange is disabled and item is not dropped into another item
// the desktop itself, not a window showing the Desktop folder
if ( $(el_item).closest('.item-container').hasClass('desktop') &&
!window.is_auto_arrange_enabled && $(el_item).attr('data-path') !== window.trash_path && !ui.helper.data('dropped') &&
// .desktop clips its overflow, so the whole item has to fit above the bottom edge
window.mouseover_window === undefined && ui.position.top + $(el_item).outerHeight(true) <= window.desktop_height ) {
el_item.style.position = 'absolute';
el_item.style.left = `${ui.position.left }px`;
el_item.style.top = `${ui.position.top }px`;
$('.ui-draggable-dragging').remove();
window.desktop_item_positions[$(el_item).attr('data-uid')] = ui.position;
window.save_desktop_item_positions();
}
$('.item-selected-clone').remove();
$('.draggable-count-badge').remove();
// re-enable all droppable UIItems that are not a dir
@@ -520,6 +547,11 @@ async function UIItem (options) {
else if ( options.is_dir ) {
// a selected folder can be the drop target of its own selection; it stays where it is
const items_to_drop = items_to_move.filter(item => $(item).attr('data-uid') !== $(el_item).attr('data-uid'));
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
// the items leaving the desktop lose their saved positions, not the folder they land in
items_to_drop.forEach(item => delete window.desktop_item_positions[$(item).attr('data-uid')]);
window.save_desktop_item_positions();
}
window.move_items(items_to_drop, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
}
}
@@ -1210,6 +1242,23 @@ async function UIItem (options) {
}
}
// -------------------------------------------
// Publish As Website
// -------------------------------------------
if ( !is_trashed && !is_trash && options.is_dir ) {
menu_items.push({
html: i18n('publish_as_website'),
disabled: !options.is_dir,
onClick: async function () {
await publish_as_website({
uid: options.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
});
},
});
}
// -------------------------------------------
// Deploy As App
// -------------------------------------------
if ( !is_trashed && !is_trash && options.is_dir ) {
@@ -1522,7 +1571,7 @@ async function UIItem (options) {
// Create ContextMenu
UIContextMenu({
parent_element: options.appendTo,
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
position: menu_position,
items: menu_items,
});
+46 -16
View File
@@ -29,6 +29,7 @@ vi.mock('./UIContextMenu.js', () => ({ default: vi.fn() }));
vi.mock('./UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/refreshItemContainer.js', () => ({ default: vi.fn() }));
@@ -175,23 +176,52 @@ describe('UIItem Publish as Worker', () => {
});
});
describe('UIItem Publish as Website', () => {
beforeEach(() => {
window.user = { username: 'alice' };
window.trash_path = '/alice/Trash';
describe('UIItem dropped on the desktop', () => {
let stop;
beforeEach(async () => {
document.body.innerHTML = '<div class="item-container desktop"></div>';
Object.assign(window, {
user: { username: 'alice' },
trash_path: '/alice/Trash',
desktop_height: 800,
is_auto_arrange_enabled: false,
mouseover_window: undefined,
desktop_item_positions: {},
save_desktop_item_positions: vi.fn(),
});
const draggable = jQuery.fn.draggable;
jQuery.fn.draggable = function (options) {
if ( typeof options === 'object' ) stop = options.stop;
return this;
};
try {
await UIItem({
appendTo: $('.item-container'),
uid: 'u-2',
path: '/alice/Desktop/Notes',
name: 'Notes',
is_dir: true,
icon: { image: '', type: 'icon' },
});
} finally {
jQuery.fn.draggable = draggable;
}
// jsdom has no stylesheet, so give the item the size style.css does.
$('.item').css({ height: '70px', margin: '15px 5px 30px 5px' });
});
it('is not offered, even on a folder the user owns', async () => {
const labels = await menuLabels({
path: '/alice/Site',
name: 'Site',
is_dir: true,
owner: 'alice',
shared_with_me: false,
share_mode: undefined,
shared_by: undefined,
});
expect(labels).not.toContain('publish_as_website');
expect(labels).toContain('deploy_as_app');
const dropAt = (top) => stop({}, { position: { top, left: 300 }, helper: $('<div>') });
it('moves there when the whole item fits on screen', () => {
dropAt(685);
expect($('.item')[0].style.top).toBe('685px');
expect(window.save_desktop_item_positions).toHaveBeenCalledTimes(1);
});
it('stays put when the item would hang off the bottom edge', () => {
dropAt(735);
expect($('.item')[0].style.top).toBe('');
expect(window.save_desktop_item_positions).not.toHaveBeenCalled();
});
});
+27 -3
View File
@@ -28,6 +28,7 @@ import { atPath } from '../helpers/pathElements.js';
import { resolve_typed_path } from '../helpers/expandHomePath.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
import launch_app from '../helpers/launchApp.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
import item_icon from '../helpers/itemIcon.js';
import { parent_path_for, shared_crumbs_for } from '../helpers/sharePaths.js';
@@ -2543,6 +2544,20 @@ async function UIWindow (options) {
// -------------------------------------------
menu_items.push('-');
// -------------------------------------------
// Publish As Website
// -------------------------------------------
menu_items.push({
html: i18n('publish_as_website'),
disabled: !options.is_dir,
onClick: async function () {
await publish_as_website({
uid: $(el_window).attr('data-uid'),
name: $(el_window).attr('data-name'),
path: $(el_window).attr('data-path'),
});
},
});
// -------------------------------------------
// Deploy as App
// -------------------------------------------
menu_items.push({
@@ -3692,7 +3707,9 @@ $.fn.close = async function (options) {
document.activeElement.blur();
$('input').blur();
}
window.active_item_container = null;
// focus back to desktop
$('.desktop').find('.item-blurred').removeClass('item-blurred');
window.active_item_container = $('.desktop.item-container').get(0);
}
});
@@ -3973,6 +3990,12 @@ $.fn.showWindow = async function (options) {
};
window.toggle_empty_folder_message = function (el_item_container) {
// if the item container is the desktop, don't show/hide the empty message
if ( $(el_item_container).hasClass('desktop') )
{
return;
}
// if the item container is empty, show the empty message
if ( $(el_item_container).has('.item').length === 0 ) {
$(el_item_container).find('.explorer-empty-message').show();
@@ -4073,8 +4096,8 @@ $.fn.focusWindow = function (event) {
// remove blurred class from items on this window
$(window.active_item_container).find('.item-blurred').removeClass('item-blurred');
// Update the tab title to the focused window. The URL itself is
// deliberately NOT touched here: on the desktop apps don't rewrite
// it, and in dashboard mode the open app owns
// deliberately NOT touched here: on the desktop it stays /desktop
// (apps don't rewrite it), and in dashboard mode the open app owns
// it through real history entries (push_dashboard_app_url).
if ( ! window.is_dashboard_mode ) {
const update_window_url = $(this).attr('data-update_window_url');
@@ -4084,6 +4107,7 @@ $.fn.focusWindow = function (event) {
}
} else {
$('.window').find('.item-selected').addClass('item-blurred');
$('.desktop').find('.item-blurred').removeClass('item-blurred');
}
return this;
+136
View File
@@ -0,0 +1,136 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindow from './UIWindow.js';
import { underPath } from '../helpers/pathElements.js';
async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_dir_path) {
let h = '';
h += '<div class="window-publishWebsite-content" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
// success
h += '<div class="window-publishWebsite-success">';
h += `<img src="${html_encode(window.icons['c-check.svg'])}" style="width:80px; height:80px; display: block; margin:10px auto;">`;
h += `<p style="text-align:center;">${i18n('dir_published_as_website', `<strong>${html_encode(target_dir_name)}</strong>`, false)}<p>`;
h += `<p style="text-align:center;"><a class="publishWebsite-published-link" target="_blank"></a><img class="publishWebsite-published-link-icon" src="${html_encode(window.icons['launch.svg'])}"></p>`;
h += `<button class="button button-normal button-block button-primary publish-window-ok-btn" style="margin-top:20px;">${i18n('ok')}</button>`;
h += '</div>';
// form
h += '<form class="window-publishWebsite-form">';
// error msg
h += '<div class="publish-website-error-msg"></div>';
// Subdomain input
h += '<div class="puter-subdomain-section" style="overflow: hidden; margin-bottom: 20px;">';
h += `<label style="margin-bottom: 10px; display: block;">${i18n('pick_name_for_website')}</label>`;
h += '<div style="font-family: monospace; display: flex; align-items: center; background: #f8f9fa; padding: 8px; border-radius: 6px; border: 1px solid #dee2e6;">';
h += `<span style="color: #666;">${html_encode(window.extractProtocol(window.url))}://</span>`;
h += '<input class="publish-website-subdomain" style="border: none; background: #ffffff; outline: none; padding: 7px !important; " type="text" autocomplete="subdomain" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>';
h += `<span style="color: #666;">.${html_encode(window.hosting_domain)}</span>`;
h += '</div>';
h += '</div>';
// uid
h += `<input class="publishWebsiteTargetDirUID" type="hidden" value="${html_encode(target_dir_uid)}"/>`;
// Publish
h += `<button class="publish-btn button button-action button-block button-normal">${i18n('publish')}</button>`;
h += '</form>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('window_title_publish_website'),
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: true,
selectable_body: false,
draggable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: true,
allow_user_select: true,
width: 450,
dominant: true,
onAppend: function (this_window) {
$(this_window).find('.publish-website-subdomain').val(window.generate_identifier());
$(this_window).find('.publish-website-subdomain').get(0).focus({ preventScroll: true });
},
window_class: 'window-publishWebsite',
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
},
});
$(el_window).find('.publish-btn').on('click', async function (e) {
e.preventDefault();
// disable 'Publish' button
$(el_window).find('.publish-btn').prop('disabled', true);
try {
let subdomain = $(el_window).find('.publish-website-subdomain').val();
if ( ! subdomain.trim() ) {
throw new Error('Please enter a subdomain name');
}
const res = await puter.hosting.create(subdomain, target_dir_path);
let url = `https://${ subdomain }.${ window.hosting_domain }/`;
// Show success
$(el_window).find('.window-publishWebsite-form').hide(100, function () {
$(el_window).find('.publishWebsite-published-link').attr('href', url);
$(el_window).find('.publishWebsite-published-link').text(url);
$(el_window).find('.window-publishWebsite-success').show(100);
$(`.item[data-uid="${target_dir_uid}"] .item-has-website-badge`).show();
});
// find all items whose path starts with target_dir_path
underPath('.item', target_dir_path).each(function () {
// show the link badge
$(this).find('.item-has-website-url-badge').show();
// update item's website_url attribute
$(this).attr('data-website_url', url + $(this).attr('data-path').substring(target_dir_path.length));
});
window.update_sites_cache();
} catch ( err ) {
const errorMessage = err.message || (err.error && err.error.message) || 'An error occurred while publishing';
$(el_window).find('.publish-website-error-msg').html(errorMessage);
$(el_window).find('.publish-website-error-msg').fadeIn();
// re-enable 'Publish' button
$(el_window).find('.publish-btn').prop('disabled', false);
}
});
$(el_window).find('.publish-window-ok-btn').on('click', function () {
$(el_window).close();
});
}
export default UIWindowPublishWebsite;
+87 -1
View File
@@ -361,15 +361,28 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel
}
.desktop {
display: none;
overflow: hidden;
height: 100vh !important;
width: 100%;
box-sizing: border-box;
display: grid;
grid-template-rows: repeat(auto-fill, 109px);
grid-auto-flow: column;
grid-template-columns: repeat(auto-fill, 120px);
padding-top: 15px;
}
.device-desktop .desktop {
padding-top: 5px;
}
.device-phone .desktop {
height: 100vh !important;
height: 100dvh !important;
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
}
.item-container-list {
@@ -871,6 +884,18 @@ span.header-sort-icon img {
margin-left: 3px;
}
/* The desktop is a plain page color, so its labels need text that reads on it. */
.desktop .item .item-name {
color: rgb(73, 73, 73);
text-shadow: none;
}
@media (prefers-color-scheme: dark) {
.desktop .item .item-name {
color: #e6e6e6;
}
}
.device-phone .item-container-list .item .item-name {
line-height: 42px;
border-bottom: 1px solid #e3e3e3;
@@ -2011,7 +2036,7 @@ label {
font-size: 14px;
}
.login-error-msg, .signup-error-msg, .form-error-msg {
.login-error-msg, .signup-error-msg, .publish-website-error-msg, .form-error-msg {
display: none;
color: red;
border: 1px solid red;
@@ -2105,6 +2130,34 @@ label {
font-size: 13px;
}
.publish-btn {
margin-top: 20px;
}
.window-publishWebsite-success, .window-give-item-access-success {
display: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: auto;
}
.publishWebsite-published-link {
text-decoration: none;
color: #007cff;
}
.publishWebsite-published-link:hover {
text-decoration: underline;
}
.publishWebsite-published-link-icon {
display: inline-block;
width: 12px;
margin-left: 5px;
margin-bottom: -1px;
user-select: none !important;
}
.login-form-title, .signup-form-title {
text-align: center;
margin-top: 10px;
@@ -5113,6 +5166,34 @@ html.dark-mode .usage-table-fade-overlay {
border-radius: 0;
}
.device-phone .desktop {
position: relative;
/* Enable smooth scrolling on iOS */
-webkit-overflow-scrolling: touch;
/* Allow vertical touch scrolling while preventing horizontal */
touch-action: pan-x;
/* Hide scrollbars while keeping functionality */
scrollbar-width: none;
-ms-overflow-style: none;
/* Scrolling behavior */
overflow-y: visible !important;
overflow-x: scroll;
padding-bottom: 60px;
}
.device-phone .desktop * {
touch-action: pan-x;
}
.device-phone .desktop::-webkit-scrollbar {
display: none;
}
/* height of 100% should not be applied to file dialogs */
.device-phone .window:not(.window-filedialog) .window-body.item-container {
height: 100%;
@@ -5141,6 +5222,11 @@ html.dark-mode .usage-table-fade-overlay {
touch-action: pan-y;
}
/* Hide desktop icons when the desktop-icons-hidden class is applied */
.desktop.item-container.desktop-icons-hidden>.item {
visibility: hidden;
}
.progress-report{
font-size:15px;
+10
View File
@@ -25,9 +25,12 @@ window.window_nav_history_current_position = {};
window.dashboard_nav_history = [];
window.dashboard_nav_history_current_position = 0;
window.progress_tracker = [];
window.upload_item_global_id = 0;
window.app_instance_ids = new Set();
window.menubars = [];
window.download_progress = [];
window.download_item_global_id = 0;
// This is the minimum width of the window for the sidebar to be shown
window.window_width_threshold_for_sidebar = 500;
@@ -125,6 +128,7 @@ window.window_counter = 0;
window.keypress_item_seach_term = '';
window.keypress_item_seach_buffer_timeout = undefined;
window.first_visit_animation = false;
window.show_twitter_link = true;
window.animate_window_opening = true;
window.animate_window_closing = true;
window.desktop_loading_fade_delay = (window.first_visit_ever && window.first_visit_animation ? 6000 : 1000);
@@ -147,6 +151,9 @@ window.launch_apps = [];
window.launch_apps.recent = [];
window.launch_apps.recommended = [];
// Map of { child_instance_id -> { parent_instance_id, launch_msg_id } }
window.child_launch_callbacks = {};
// Is puter being loaded inside an iframe?
if ( window.location !== window.parent.location ) {
window.is_embedded = true;
@@ -209,6 +216,9 @@ window.feature_flags = {
download_directory: true,
};
window.is_auto_arrange_enabled = true;
window.desktop_item_positions = {};
window.reset_item_positions = true; // The variable decides if the item positions should be reset when the user enabled auto arrange
window.file_templates = [];
+104
View File
@@ -2819,6 +2819,10 @@ window.extractSubdomain = function (url) {
return subdomain;
};
window.extractProtocol = function (url) {
var protocol = url.split('://')[0];
return protocol;
};
window.sleep = function (ms) {
return new Promise(resolve => setTimeout(resolve, ms));
};
@@ -3416,6 +3420,57 @@ window.undo_delete = async (items) => {
}
};
window.store_auto_arrange_preference = (preference) => {
// localStorage still carries it for this device either way.
puter.kv.set('user_preferences.auto_arrange_desktop', preference)
.catch(err => console.warn('Could not save auto_arrange_desktop:', err));
localStorage.setItem('auto_arrange', preference);
};
window.get_auto_arrange_data = async () => {
// Best-effort, like every other preference read: falling back to the
// default keeps the desktop arranging itself rather than not appearing.
const preferenceValue = await puter.kv.get('user_preferences.auto_arrange_desktop').catch(() => null);
window.is_auto_arrange_enabled = preferenceValue === null ? true : preferenceValue;
const positions = await puter.kv.get('desktop_item_positions').catch(() => null);
window.desktop_item_positions = (!positions || typeof positions !== 'object' || Array.isArray(positions)) ? {} : positions;
};
window.clear_desktop_item_positions = async (el_desktop) => {
$(el_desktop).find('.item').each(function () {
const el_item = $(this)[0];
$(el_item).css('position', '');
$(el_item).css('left', '');
$(el_item).css('top', '');
});
if ( window.reset_item_positions ) {
window.delete_desktop_item_positions();
}
};
window.set_desktop_item_positions = async (el_desktop) => {
$(el_desktop).find('.item').each(async function () {
const position = window.desktop_item_positions[$(this).attr('data-uid')];
const el_item = $(this)[0];
if ( position ) {
$(el_item).css('position', 'absolute');
$(el_item).css('left', `${position.left }px`);
$(el_item).css('top', `${position.top }px`);
}
});
};
window.save_desktop_item_positions = () => {
puter.kv.set('desktop_item_positions', window.desktop_item_positions)
.catch(err => console.warn('Could not save desktop_item_positions:', err));
};
window.delete_desktop_item_positions = () => {
window.desktop_item_positions = {};
puter.kv.del('desktop_item_positions')
.catch(err => console.warn('Could not clear desktop_item_positions:', err));
};
// Finds the `.window` element for the given app instance ID
window.window_for_app_instance = (instance_id) => {
return $(`.window[data-element_uuid="${instance_id}"]`).get(0);
@@ -3471,6 +3526,22 @@ window.set_menu_item_prop = (items, item_id, prop, val) => {
}
};
window.countSubstr = (str, substring) => {
if ( substring.length === 0 ) {
return 0;
}
let count = 0;
let pos = str.indexOf(substring);
while ( pos !== -1 ) {
count++;
pos = str.indexOf(substring, pos + 1);
}
return count;
};
// Updates the signed-in user's profile; `username` is kept for callers that
// still pass it.
window.update_profile = function (username, key_vals) {
@@ -3485,11 +3556,44 @@ window.update_profile = function (username, key_vals) {
});
};
window.blob2str = (blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(blob);
});
};
// Another user's picture is only available while they are on a paid plan.
window.get_profile_picture = async function (username) {
return (await puter.auth.getProfilePicture(username)) ?? undefined;
};
window.format_with_units = (num, { mulUnits, divUnits, precision = 3 }) => {
if ( num === 0 ) return '0';
mulUnits = mulUnits ?? ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
divUnits = divUnits ?? ['m', 'µ', 'n', 'p', 'f', 'a', 'z', 'y'];
const abs = Math.abs(num);
let exp = Math.floor(Math.log10(abs) / 3);
let symbol = '';
symbol = exp >= 0
? mulUnits[exp]
: divUnits[-exp - 1] ;
if ( ! symbol ) {
symbol = `e${exp * 3}`;
}
const scaled = num / Math.pow(10, exp * 3);
const rounded = Number.parseFloat(scaled.toPrecision(precision));
return `${rounded}${symbol}`;
};
/**
* General-purpose number formatting function with support for decimal places,
* thousand separators, and various formatting options.
+2 -2
View File
@@ -57,8 +57,8 @@ describe('authLogoHeader', () => {
});
it('makes the logo a named link when it leads somewhere', () => {
const h = authLogoHeader({ logoSrc: LOGO, logoHref: '/' });
expect(h).toContain('<a class="auth-logo-link" href="/">');
const h = authLogoHeader({ logoSrc: LOGO, logoHref: '/desktop' });
expect(h).toContain('<a class="auth-logo-link" href="/desktop">');
expect(h).toContain('alt="Puter"');
});
+6 -5
View File
@@ -63,11 +63,11 @@ export const get_auth_redirect_url = () => {
*/
const oidc_return_path = () => {
const pathname = window.location.pathname;
if ( pathname === '/' || pathname === '/dashboard' ) {
if ( pathname === '/' || pathname === '/desktop' || pathname === '/dashboard' ) {
return pathname;
}
// app landing: normalize away a trailing slash to match the backend whitelist
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) {
return pathname.replace(/\/$/, '');
}
return null;
@@ -76,9 +76,10 @@ const oidc_return_path = () => {
/**
* The `return_to` to send along when starting an OIDC flow, or null if the
* current page isn't one the backend will return to. The backend strictly
* whitelists these (never a client-supplied URL): `/dashboard` and direct app
* landings (`/app/<name>`), so OIDC login started from an app landing comes
* back to the app.
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
* and direct app landings (`/app/<name>`, plus the desktop-booted
* `/desktop/app/<name>`), so OIDC login started from an app landing comes back
* to the app — and to the same interface it was opened in.
*
* A share link (`?shared=`, from an email) is carried along with the path: the
* recipient usually has to sign in before they can see what was shared, and an
+11 -7
View File
@@ -42,6 +42,7 @@ afterEach(() => {
describe('get_oidc_return_to', () => {
it('returns the interface pages the backend whitelists', () => {
expect(at('/desktop')).toBe('/desktop');
expect(at('/dashboard')).toBe('/dashboard');
});
@@ -50,12 +51,17 @@ describe('get_oidc_return_to', () => {
expect(at('/app/editor/')).toBe('/app/editor');
});
it('keeps a desktop app landing on the desktop', () => {
expect(at('/desktop/app/editor')).toBe('/desktop/app/editor');
expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
});
it('carries a share link so the item survives the round trip', () => {
expect(at('/', share_search(shared_path('Report.pdf')))).toBe(
`/${share_search(shared_path('Report.pdf'))}`,
);
expect(at('/dashboard', share_search(shared_path('Report.pdf')))).toBe(
`/dashboard${share_search(shared_path('Report.pdf'))}`,
expect(at('/desktop', share_search(shared_path('Report.pdf')))).toBe(
`/desktop${share_search(shared_path('Report.pdf'))}`,
);
expect(
at('/', share_search(shared_path('a.txt'), shared_path('b.txt'))),
@@ -77,10 +83,10 @@ describe('get_oidc_return_to', () => {
expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
expect(at('/', '?shared=')).toBe(null);
// other parameters are not ours to carry
expect(at('/dashboard', '?app=editor')).toBe('/dashboard');
expect(at('/desktop', '?app=editor')).toBe('/desktop');
expect(
at('/dashboard', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/dashboard${share_search(shared_path('a.txt'))}`);
at('/desktop', `${share_search(shared_path('a.txt'))}&app=editor`),
).toBe(`/desktop${share_search(shared_path('a.txt'))}`);
});
it('returns null for anything the backend would reject', () => {
@@ -88,8 +94,6 @@ describe('get_oidc_return_to', () => {
expect(at('/settings')).toBe(null);
expect(at('/action/login')).toBe(null);
expect(at('/app/editor/extra')).toBe(null);
expect(at('/desktop')).toBe(null);
expect(at('/desktop/app/editor')).toBe(null);
expect(at('/desktop/app/editor/extra')).toBe(null);
expect(at('/dashboard/app/editor')).toBe(null);
});
-36
View File
@@ -1,36 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Whether a page load boots the dashboard instead of the desktop. The root
* path and `/app/<name>` boot the dashboard unless the page is an iframe embed
* or a popup, which the desktop still handles; `/dashboard` always does.
*
* @param {Object} location
* @param {string} location.pathname
* @param {boolean} location.inIframe
* @param {boolean} location.embeddedInPopup
* @returns {boolean}
*/
export function bootsDashboard ({ pathname, inIframe, embeddedInPopup }) {
if ( pathname === '/dashboard' || pathname === '/dashboard/' ) return true;
const isAppLanding = /^\/app\/[^/]+\/?$/.test(pathname);
if ( pathname !== '/' && !isAppLanding ) return false;
return !embeddedInPopup && !inIframe;
}
@@ -1,58 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect } from 'vitest';
import { bootsDashboard } from './bootsDashboard.js';
const boots = (url, { inIframe = false, embeddedInPopup = false } = {}) => {
const { pathname } = new URL(url, 'https://puter.com');
return bootsDashboard({ pathname, inIframe, embeddedInPopup });
};
describe('bootsDashboard', () => {
it('boots the dashboard at the root, its alias, and app landings', () => {
expect(boots('/')).toBe(true);
expect(boots('/dashboard')).toBe(true);
expect(boots('/dashboard/')).toBe(true);
expect(boots('/app/editor')).toBe(true);
});
it('boots the desktop on action pages', () => {
expect(boots('/action/sign-in')).toBe(false);
});
it('boots the desktop for flows only the desktop handles', () => {
expect(boots('/', { inIframe: true })).toBe(false);
expect(boots('/', { embeddedInPopup: true })).toBe(false);
});
it('keeps /dashboard on the dashboard even inside an iframe', () => {
expect(boots('/dashboard', { inIframe: true })).toBe(true);
});
it('boots the dashboard whatever the query asks for', () => {
expect(boots('/?shared=%2Fann%2Fuid%2Fnotes.txt')).toBe(true);
expect(boots('/?download=https%3A%2F%2Fexample.com%2Ffile.zip')).toBe(true);
expect(boots('/?app=explorer&path=%2Falice%2FDocuments')).toBe(true);
expect(boots('/app/editor?app=explorer')).toBe(true);
expect(boots('/app/editor?puter.fullpage=true')).toBe(true);
expect(boots('/app/editor?puter.fullpage=false')).toBe(true);
expect(boots('/?puter.fullpage=1')).toBe(true);
});
});
+11 -5
View File
@@ -20,6 +20,7 @@
import UIAlert from '../UI/UIAlert.js';
import UIWindowItemProperties from '../UI/UIWindowItemProperties.js';
import publish_as_website from './publishAsWebsite.js';
import open_item from './openItem.js';
import openWithAI from './openWithAI.js';
import launch_app from './launchApp.js';
@@ -43,7 +44,7 @@ import { is_default_app } from './defaultApp.js';
* @param {boolean} options.openWithInNewTab - Open the "Open With" apps in a new browser tab instead of an in-page window (used by Dashboard)
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onShare - Opens sharing for the item; Share is left out of the menu without it. Receives {name, path, uid, element}
* @param {Function} options.onPublishWebsite - Publishes the folder as a website; Publish as Website is left out of the menu without it. Receives {uid, name, path}
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPublishWorker - Publishes the file as a worker; Publish as Worker is left out of the menu without it. Receives {uid, name, path}
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has dropped the user's own access (used by Dashboard)
@@ -134,19 +135,24 @@ const generate_file_context_menu = async function (options) {
// -------------------------------------------
// Publish As Website
// -------------------------------------------
if ( !is_trashed && !is_trash && fsentry.is_dir && options.onPublishWebsite ) {
if ( !is_trashed && !is_trash && fsentry.is_dir ) {
menu_items.push({
html: i18n('publish_as_website'),
// Publishing serves the folder to anyone, for good, so it takes the
// same own-it-or-`manage` right as sharing it does (SubdomainDriver
// enforces that). `write` on someone else's folder is not enough.
disabled: !fsentry.is_dir || fsentry.has_website || !may_share,
onClick: function () {
options.onPublishWebsite({
onClick: async function () {
const target = {
uid: fsentry.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
});
};
if ( options.onPublishWebsite ) {
options.onPublishWebsite(target);
} else {
await publish_as_website(target);
}
},
});
}
@@ -23,6 +23,7 @@ import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIWindowItemProperties.js', () => ({ default: vi.fn() }));
vi.mock('./publishAsWebsite.js', () => ({ default: vi.fn() }));
vi.mock('./openItem.js', () => ({ default: vi.fn() }));
vi.mock('./openWithAI.js', () => ({ default: vi.fn() }));
vi.mock('./launchApp.js', () => ({ default: vi.fn() }));
@@ -113,37 +114,6 @@ describe('Share', () => {
});
});
describe('Publish as Website', () => {
const publishItem = async (options) => {
is_owned_by_me.mockReturnValueOnce(true);
can_share.mockResolvedValueOnce(true);
const row = document.createElement('div');
row.setAttribute('data-path', '/me/Site');
row.setAttribute('data-name', 'Site');
const items = await generate_file_context_menu({
element: row,
fsentry: { uid: 'dir-1', name: 'Site', path: '/me/Site', is_dir: true },
associated_app_name: 'editor',
...options,
});
return items.find((item) => item !== '-' && item.html === 'publish_as_website');
};
it('hands the folder to onPublishWebsite', async () => {
const onPublishWebsite = vi.fn();
await (await publishItem({ onPublishWebsite })).onClick();
expect(onPublishWebsite).toHaveBeenCalledWith({
uid: 'dir-1',
name: 'Site',
path: '/me/Site',
});
});
it('is left out without onPublishWebsite', async () => {
expect(await publishItem({})).toBeUndefined();
});
});
describe('Publish as Worker', () => {
const publishItem = async (options) => {
is_owned_by_me.mockReturnValueOnce(true);
+3
View File
@@ -106,6 +106,9 @@ const runLaunch = async (options) => {
const launchAttributes = {
'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown',
'launch.dashboard_mode': !! window.is_dashboard_mode,
// url_paths has the `/desktop` prefix stripped, so this counts the
// desktop-booted landing (`/desktop/app/<name>`) as the app URL
// it is.
'launch.from_app_url':
window.url_paths?.[0]?.toLocaleLowerCase() === 'app'
&& !! window.url_paths?.[1],
+3 -5
View File
@@ -84,9 +84,8 @@ export function shared_link_recipient_uuid (params) {
* that account's saved session, ask whether to sign in to it, or nothing.
*
* Popups, embeds (`embedded`) and `action` flows act for whoever opened them,
* so a link must not change their account. The desktop (`desktop`) doesn't
* open share links, so it never acts on the hint. A temporary session is left
* to the share link's own sign-in prompt.
* so a link must not change their account. A temporary session is left to the
* share link's own sign-in prompt.
*
* @returns {{ switch_to: object } | { ask: true } | null}
*/
@@ -94,11 +93,10 @@ export function shared_link_account_step ({
params,
action,
embedded,
desktop,
current_user,
logged_in_users,
}) {
if ( action || embedded || desktop ) return null;
if ( action || embedded ) return null;
const uuid = shared_link_recipient_uuid(params);
if ( ! uuid || uuid === current_user?.uuid?.toLowerCase() ) return null;
const saved = (logged_in_users ?? []).find(user =>
@@ -127,10 +127,6 @@ describe('shared_link_account_step', () => {
expect(step({ action: 'login' })).toBeNull();
});
it('leaves the desktop alone, since it does not open share links', () => {
expect(step({ desktop: true })).toBeNull();
});
it('leaves a temporary session to the share link sign-in', () => {
const temp = { uuid: CURRENT, is_temp: true };
expect(step({ current_user: temp, logged_in_users: [temp] }))
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindowPublishWebsite from '../UI/UIWindowPublishWebsite.js';
import ensure_can_publish from './ensureCanPublish.js';
/**
* Opens the "Publish as website" flow for a directory, first walking the user
* through account creation and email confirmation when the deployment requires
* a verified email to publish. Returns without publishing if the user backs out
* of either step.
*
* @param {Object} dir - The directory to publish
* @param {string} dir.uid - UID of the directory
* @param {string} dir.name - Display name of the directory
* @param {string} dir.path - Full path of the directory
* @returns {Promise<void>}
*/
const publish_as_website = async function ({ uid, name, path }) {
if ( ! await ensure_can_publish(window.require_email_verification_to_publish_website) ) return;
await UIWindowPublishWebsite(uid, name, path);
};
export default publish_as_website;
@@ -246,6 +246,7 @@ const refresh_item_container = function (el_item_container, options) {
if ( item_path !== window.trash_path && item_path !== window.appdata_path ) {
// if this is trash, get original name from item metadata
fsentry.name = metadata.original_name ?? fsentry.name;
const position = window.desktop_item_positions[fsentry.uid] ?? undefined;
UIItem({
appendTo: el_item_container,
uid: fsentry.uid,
@@ -268,6 +269,7 @@ const refresh_item_container = function (el_item_container, options) {
suggested_apps: fsentry.suggested_apps,
disabled: is_disabled,
visible: visible,
position: position,
shared_with_me: fsentry.shared_with_me,
share_mode: fsentry.share_mode,
shared_by: fsentry.shared_by,
@@ -279,6 +281,31 @@ const refresh_item_container = function (el_item_container, options) {
}
}
// if this is desktop, add Trash
if ( $(el_item_container).hasClass('desktop') ) {
try {
const trash = await puter.fs.stat({ path: window.trash_path, consistency: options.consistency ?? 'eventual' });
if ( ! is_current() ) {
return;
}
UIItem({
appendTo: el_item_container,
uid: trash.id,
immutable: trash.immutable,
path: window.trash_path,
icon: { image: (trash.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']), type: 'icon' },
name: trash.name,
is_dir: trash.is_dir,
sort_by: trash.sort_by,
type: trash.type,
is_trash: true,
sortable: false,
});
window.sort_items(el_item_container, $(el_item_container).attr('data-sort_by'), $(el_item_container).attr('data-sort_order'));
} catch (e) {
// Ignored
}
}
// sort items
window.sort_items(
el_item_container,
@@ -47,6 +47,7 @@ Object.assign(window, {
shared_path: '/alice/Shared',
trash_path: '/alice/Trash',
user_preferences: {},
desktop_item_positions: {},
check_fsentry_against_allowed_file_types_string: () => true,
sort_items: () => {},
update_explorer_footer_item_count: () => {},
+17 -4
View File
@@ -21,9 +21,22 @@
* Splits a GUI pathname into the route segments the boot code matches on
* (`window.url_paths`), dropping empty segments.
*
* @param {string} pathname - the pathname to parse, e.g. `/app/editor`
* A leading `desktop` segment is dropped with them: `/desktop` only picks the
* interface, and everything after it is the same route it would be at the
* root. So `/desktop/app/<name>` yields `['app', '<name>']` — the app landing
* every route check downstream already understands — and the only difference
* from `/app/<name>` is that the dashboard-mode check (see initgui.js) doesn't
* claim the path, leaving the app to open on the desktop.
*
* @param {string} pathname - the pathname to parse, e.g. `/desktop/app/editor`
* @returns {string[]} route segments
*/
export const parse_url_paths = (pathname) => String(pathname ?? '')
.split('/')
.filter((element) => element);
export const parse_url_paths = (pathname) => {
const paths = String(pathname ?? '')
.split('/')
.filter((element) => element);
if ( paths[0]?.toLocaleLowerCase() === 'desktop' ) {
paths.shift();
}
return paths;
};
+20 -3
View File
@@ -28,12 +28,29 @@ describe('parse_url_paths', () => {
expect(parse_url_paths('')).toEqual([]);
});
it('keeps a leading desktop segment, so /desktop/app/<name> is not an app landing', () => {
expect(parse_url_paths('/desktop')).toEqual(['desktop']);
it('drops a leading desktop segment so the rest reads as a root route', () => {
expect(parse_url_paths('/desktop/app/editor')).toEqual([
'desktop',
'app',
'editor',
]);
expect(parse_url_paths('/DeskTop/app/editor')).toEqual([
'app',
'editor',
]);
expect(parse_url_paths('/desktop')).toEqual([]);
expect(parse_url_paths('/desktop/')).toEqual([]);
});
it('only drops the prefix, not a later or lookalike segment', () => {
expect(parse_url_paths('/app/desktop')).toEqual(['app', 'desktop']);
expect(parse_url_paths('/desktops/app/editor')).toEqual([
'desktops',
'app',
'editor',
]);
expect(parse_url_paths('/desktop/desktop/app')).toEqual([
'desktop',
'app',
]);
});
});
+4
View File
@@ -31,6 +31,7 @@ const ar = {
allow: 'السماح',
ascending: 'تصاعدي',
associated_websites: 'المواقع المرتبطة',
auto_arrange: 'ترتيب تلقائي',
cancel: 'إلغاء',
change_email: 'تغيير البريد الإلكتروني',
change_language: 'تغيير اللغة',
@@ -91,6 +92,7 @@ const ar = {
desktop: 'سطح المكتب',
desktop_background_fit: 'ملائمة',
developers: 'المطورين',
dir_published_as_website: ':%strong% تم نشره إلى',
disable_2fa: 'تعطيل المصادقة الثنائية',
disable_2fa_confirm: 'هل أنت متأكد أنك تريد تعطيل المصادقة الثنائية؟',
disable_2fa_instructions: '.أدخل كلمة المرور لتعطيل المصادقة الثنائية',
@@ -98,6 +100,7 @@ const ar = {
dont_allow: 'عدم السماح',
download: 'تنزيل',
download_file: 'تنزيل الملف',
downloading: 'جارٍ التنزيل',
email: 'البريد الإلكتروني',
email_change_confirmation_sent:
'.تم إرسال بريد تأكيد إلى عنوان بريدك الإلكتروني الجديد. يرجى التحقق من صندوق الوارد واتباع التعليمات لإكمال العملية',
@@ -186,6 +189,7 @@ const ar = {
paste_into_folder: 'لصق في المجلد',
path: 'المسار',
personalization: 'تخصيص',
pick_name_for_website: 'اختر اسمًا لموقعك الإلكتروني:',
pictures: 'الصور',
plural_suffix: 's', //this is not necessary for Arabic
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} مدعوم بواسطة ',
+4
View File
@@ -31,6 +31,7 @@ const bg = {
allow: 'Разреши',
ascending: 'Възходящ',
associated_websites: 'Свързани уебсайтове',
auto_arrange: 'Автоматично подреждане',
cancel: 'Отказ',
change: 'Промени',
change_always_open_with: 'Искате ли винаги да отваряте този тип файл с',
@@ -97,6 +98,7 @@ const bg = {
desktop: 'Работен плот',
desktop_background_fit: 'Побери',
developers: 'Разработчици',
dir_published_as_website: '%strong% беше публикувана на:',
disable_2fa: 'Изключи 2FA',
disable_2fa_confirm: 'Сигурни ли сте, че искате да изключите 2FA?',
disable_2fa_instructions: 'Въведете паролата си, за да изключите 2FA.',
@@ -104,6 +106,7 @@ const bg = {
dont_allow: 'Не разрешавай',
download: 'Изтегли',
download_file: 'Изтегли файл',
downloading: 'Изтегляне',
email: 'Имейл',
email_change_confirmation_sent:
'Потвърдителен имейл беше изпратен на новия ви имейл адрес. Моля, проверете входящата си поща и следвайте инструкциите, за да завършите процеса.',
@@ -195,6 +198,7 @@ const bg = {
paste_into_folder: 'Постави в папката',
path: 'Път',
personalization: 'Персонализация',
pick_name_for_website: 'Изберете име за вашия уебсайт:',
pictures: 'Снимки',
plural_suffix: 'и',
powered_by_puter_js: 'Работи с {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const bn = {
allow: 'অনুমতি দিন',
ascending: 'ঊর্ধ্বক্রমে',
associated_websites: 'সংযুক্ত ওয়েবসাইটগুলি',
auto_arrange: 'অটো বিন্যাস',
cancel: 'বাতিল করুন',
change_email: 'ই-মেইল পরিবর্তন করুন',
change_language: 'ভাষা পরিবর্তন করুন',
@@ -84,6 +85,7 @@ const bn = {
desktop: 'ডেস্কটপ',
desktop_background_fit: 'ফিট',
developers: 'ডেভেলপারগণ',
dir_published_as_website: '%strong% প্রকাশিত হয়েছে:',
disable_2fa: '2FA অক্ষম করুন',
disable_2fa_confirm: 'আপনি কি নিশ্চিত যে আপনি 2FA অক্ষম করতে চান?',
disable_2fa_instructions: '2FA অক্ষম করতে আপনার পাসওয়ার্ড লিখুন।',
@@ -91,6 +93,7 @@ const bn = {
dont_allow: 'অনুমতি নেই',
download: 'ডাউনলোড',
download_file: 'ফাইল ডাউনলোড করুন',
downloading: 'ডাউনলোড হচ্ছে',
email: 'ই-মেইল',
email_change_confirmation_sent: 'নতুন ই-মেইল ঠিকানা নিশ্চিতকরণের জন্য একটি নিশ্চিতকরণ ইমেল পাঠানো হয়েছে। আপনার ইনবক্স পরীক্ষা করুন এবং নির্দেশানুযায়ী প্রক্রিয়াটি সম্পন্ন করুন।',
email_invalid: 'ই-মেইল অবৈধ।',
@@ -173,6 +176,7 @@ const bn = {
paste_into_folder: 'ফোল্ডারে পেস্ট করুন',
path: 'পথ',
personalization: 'ব্যক্তিগতীকরণ',
pick_name_for_website: 'আপনার ওয়েবসাইটের জন্য নাম নির্বাচন করুন:',
pictures: 'চিত্র',
plural_suffix: 'গুলি',
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} দ্বারা প্রচালিত',
+4
View File
@@ -31,6 +31,7 @@ const br = {
allow: 'Permitir',
ascending: 'Ascendente',
associated_websites: 'Sites Associados',
auto_arrange: 'Organizar Automaticamente',
cancel: 'Cancelar',
change_email: 'Mudar Email',
change_language: 'Mudar Idioma',
@@ -84,6 +85,7 @@ const br = {
desktop: 'Área de Trabalho',
desktop_background_fit: 'Ajustar',
developers: 'Desenvolvedores',
dir_published_as_website: '%strong% foi publicado em:',
disable_2fa: 'Desativar 2FA',
disable_2fa_confirm: 'Tem certeza de que deseja desativar 2FA?',
disable_2fa_instructions: 'Digite sua senha para desativar 2FA.',
@@ -91,6 +93,7 @@ const br = {
dont_allow: 'Não Permitir',
download: 'Baixar',
download_file: 'Baixar Arquivo',
downloading: 'Baixando',
email: 'Email',
email_change_confirmation_sent: 'Um e-mail de confirmação foi enviado para seu novo endereço de e-mail. Por favor, verifique sua caixa de entrada e siga as instruções para concluir o processo.',
email_invalid: 'Email inválido.',
@@ -170,6 +173,7 @@ const br = {
'paste_into_folder': 'Colar na Pasta',
'path': 'Caminho',
'personalization': 'Personalização',
'pick_name_for_website': 'Escolha um nome para o seu site:',
'pictures': 'Imagens',
'plural_suffix': 's',
'powered_by_puter_js': 'Desenvolvido por {{link=docs}}Puter.js{{/link}}',
+9
View File
@@ -62,6 +62,7 @@ const cs = {
"app_group_remove_from_folder": "Odebrat ze složky",
"associated_websites": "Přidružené weby",
"app_feedback_sent": "Zpětná vazba byla odeslána. Děkujeme!",
"auto_arrange": "Automaticky uspořádat",
"browser_version": "Verze prohlížeče",
"change_language": "Změnit jazyk",
"captcha_required": "Dokončete prosím ověření CAPTCHA",
@@ -130,6 +131,7 @@ const cs = {
"deleting_file": "Mazání %%",
"directory_depth_limit_exceeded": "Tato složka je vnořena příliš hluboko. Než do ní přidáte další složky, přesuňte ji někam blíže ke kořeni.",
"disable_2fa_confirm": "Opravdu chcete deaktivovat 2FA?",
"dir_published_as_website": "%strong% byl publikován pro:",
"documents": "Dokumenty",
"dont_allow": "Nepovolit",
"date_modified": "Datum změny",
@@ -138,11 +140,15 @@ const cs = {
"client_information": "Informace o klientovi",
"download": "Stáhnout",
"download_file": "Stáhnout soubor",
"downloading": "Stahování",
"email": "E-mail",
"confirm_download_file_to_desktop": "Opravdu chcete stáhnout %% na plochu?",
"email_change_confirmation_sent": "Na novou e-mailovou adresu byl odeslán potvrzovací e-mail. Zkontrolujte doručenou poštu a dokončete postup podle pokynů.",
"disk_storage": "Diskové úložiště",
"email_invalid": "E-mail je neplatný.",
"error_download_failed": "Stažení souboru se nezdařilo",
"disable_2fa_instructions": "Pro deaktivaci 2FA zadejte své heslo.",
"downloading_file": "Stahování %%",
"email_required": "E-mail je povinný.",
"empty_trash": "Vysypat koš",
"emptying_trash": "Vysypávání koše…",
@@ -253,6 +259,7 @@ const cs = {
"privacy": "Soukromí",
"powered_by_puter_js": "Používá technologii {{link=docs}}Puter.js{{/link}}",
"proceed_with_account_deletion": "Pokračujte ve smazání účtu",
"pick_name_for_website": "Vyberte název pro svůj web:",
"phone_code_sent_whatsapp": "Zadejte 6místný kód odeslaný přes WhatsApp na",
"phone_code_sent_to": "Zadejte 6místný kód odeslaný na",
"properties": "Vlastnosti",
@@ -430,6 +437,7 @@ const cs = {
"username": "Uživatelské jméno",
"username_required": "Uživatelské jméno je povinné.",
"unzipping": "Rozbalení %strong%",
"uploading_file": "Nahrávání %%",
"untarring": "Rozbalování %strong%",
"visibility": "Viditelnost",
"videos": "Videa",
@@ -603,6 +611,7 @@ const cs = {
"error_user_or_path_not_found": "Uživatel nebo cesta nenalezena.",
"token_copied": "Token zkopírován",
"copy_auth_token": "Kopírovat ověřovací token",
"error_invalid_username": "Neplatné uživatelské jméno.",
"perm_dialog_wants_to": "chce povolení",
"token_label": "Označení",
"token_label_required": "Zadejte štítek pro tento token.",
+4
View File
@@ -31,6 +31,7 @@ const da = {
allow: 'Tillad',
ascending: 'Stigende',
associated_websites: 'Tilknyttede websteder',
auto_arrange: 'Auto Arrangere',
cancel: 'Annuller',
change_email: 'Ændre e-mail',
change_language: 'Ændre sprog',
@@ -84,6 +85,7 @@ const da = {
desktop: 'Skrivebord',
desktop_background_fit: 'Tilpas',
developers: 'Udviklere',
dir_published_as_website: '%strong% er blevet offentliggjort til:',
disable_2fa: 'Deaktiver 2FA',
disable_2fa_confirm: 'Er du sikker på, at du vil deaktivere 2FA?',
disable_2fa_instructions: 'Indtast din adgangskode for at deaktivere 2FA.',
@@ -91,6 +93,7 @@ const da = {
dont_allow: 'Tillad ikke',
download: 'Download',
download_file: 'Download fil',
downloading: 'Downloader',
email: 'E-mail',
email_change_confirmation_sent: 'En bekræftelses-e-mail er sendt til din nye e-mailadresse. Kontroller din indbakke og følg instruktionerne for at fuldføre processen.',
email_invalid: 'E-mail er ugyldig.',
@@ -170,6 +173,7 @@ const da = {
paste_into_folder: 'Sæt ind i mappe',
path: 'Sti',
personalization: 'Personalisering',
pick_name_for_website: 'Vælg et navn til dit websted:',
pictures: 'Billeder',
plural_suffix: 's',
powered_by_puter_js: 'Udviklet af {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const de = {
allow: 'Erlauben',
ascending: 'Aufsteigend',
associated_websites: 'Zugeordnete Webseiten',
auto_arrange: 'Automatisch anordnen',
cancel: 'Abbrechen',
change_email: 'E-Mail ändern',
change_language: 'Sprache ändern',
@@ -83,6 +84,7 @@ const de = {
desktop: 'Desktop',
desktop_background_fit: 'Passend',
developers: 'Entwickler',
dir_published_as_website: '%strong% wurde veröffentlicht unter:',
disable_2fa: '2FA deaktivieren',
disable_2fa_confirm: 'Möchten Sie 2FA wirklich deaktivieren?',
disable_2fa_instructions: 'Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren.',
@@ -90,6 +92,7 @@ const de = {
dont_allow: 'Nicht erlauben',
download: 'Herunterladen',
download_file: 'Datei herunterladen',
downloading: 'Lädt herunter',
email: 'E-Mail',
email_change_confirmation_sent: 'Eine Bestätigungs-E-Mail wurde an Ihre neue E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang und folgen Sie den Anweisungen, um den Vorgang abzuschließen.',
email_invalid: 'E-Mail ist ungültig.',
@@ -169,6 +172,7 @@ const de = {
paste_into_folder: 'In Ordner einfügen',
path: 'Pfad',
personalization: 'Personalisierung',
pick_name_for_website: 'Wählen Sie einen Namen für Ihre Webseite:',
pictures: 'Bilder',
plural_suffix: 's',
powered_by_puter_js: 'Betrieben von {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -26,6 +26,7 @@ const emoji = {
add_another_account: '➕👤',
all_fields_required: '📝🔒✅',
ascending: '🔼',
auto_arrange: '🔄📂📄',
cancel: '❌',
change_language: '🔄🌐',
change_password: '🔑🔄',
@@ -52,8 +53,10 @@ const emoji = {
deploy_as_app: '🚀📱',
descending: '🔽',
desktop_background_fit: '🖥️🖼️',
dir_published_as_website: '📂📰🌐',
download: '⬇️',
download_file: '⬇️📄',
downloading: '⬇️➡️',
email: '📧',
email_or_username: '📧👤',
empty_trash: '🗑️🆓',
@@ -99,6 +102,7 @@ const emoji = {
passwords_do_not_match: '🔑❌🔑',
paste: '📋➡️',
paste_into_folder: '📋➡️📂',
pick_name_for_website: '🌐📛❓:',
powered_by_puter_js: '⚙️🔌🔗',
preparing: '🔄🔜',
preparing_for_upload: '🔄🔜',
+9
View File
@@ -67,6 +67,7 @@ const en = {
app_launch_no_access_unnamed: "You don't have access to this app.",
ascending: 'Ascending',
associated_websites: 'Associated Websites',
auto_arrange: 'Auto Arrange',
back: 'Back',
bio: 'Bio',
browser: 'Browser',
@@ -214,6 +215,7 @@ const en = {
desktop: 'Desktop',
desktop_background_fit: 'Fit',
developers: 'Developers',
dir_published_as_website: '%strong% has been published to:',
directory_depth_limit_exceeded: 'This folder is nested too deeply. Move it somewhere closer to the top before adding more folders inside it.',
disable_2fa: 'Disable 2FA',
disable_2fa_confirm: 'Are you sure you want to disable 2FA?',
@@ -227,8 +229,12 @@ const en = {
done: 'Done',
dont_allow: 'Don\'t Allow',
download: 'Download',
confirm_download_file_to_desktop: 'Are you sure you want to download %% to your Desktop?',
download_file: 'Download File',
downloading: 'Downloading',
downloading_file: 'Downloading %%',
drag_to_reposition: 'Drag to reposition',
error_download_failed: 'Failed to download file',
email: 'Email',
email_change_confirmation_sent: 'A confirmation email has been sent to your new email address. Please check your inbox and follow the instructions to complete the process.',
email_invalid: 'Email is invalid.',
@@ -414,6 +420,7 @@ const en = {
phone_card_fallback_link: 'Verify with a card instead',
phone_card_fallback_note: "We only check that the card is valid \u2014 you won't be charged.",
phone_card_fallback_unavailable: "Card verification isn't available right now. Please try your phone number again, or email [email protected] and we'll help you finish setting up your account.",
pick_name_for_website: 'Pick a name for your website:',
pictures: 'Pictures',
pixel_ratio: 'Pixel Ratio',
plural_suffix: 's',
@@ -776,6 +783,7 @@ const en = {
untarring: 'Untarring %strong%',
upload: 'Upload',
uploading: 'Uploading',
uploading_file: 'Uploading %%',
upload_here: 'Upload here',
uptime: 'Uptime',
used_of: '{{used}} used of {{available}}',
@@ -1046,6 +1054,7 @@ const en = {
'enable_push_footnote': 'You can turn them off anytime in Settings.',
'error_user_or_path_not_found': 'User or path not found.',
'error_invalid_username': 'Invalid username.',
// Auth token
auth_token: 'Auth Token',
+4
View File
@@ -38,6 +38,7 @@ const es = {
allow: 'Permitir',
ascending: 'Ascendiente',
associated_websites: 'Sitios Web Asociados',
auto_arrange: 'Organización Automática',
cancel: 'Cancelar',
change_email: 'Cambiar Correo Electrónico',
change_language: 'Cambiar Idioma',
@@ -100,6 +101,7 @@ const es = {
desktop: 'Escritorio',
desktop_background_fit: 'Ajustar',
developers: 'Desarrolladores',
dir_published_as_website: '%strong% ha sido publicado en:',
disable_2fa: 'Deshabilitar 2FA',
disable_2fa_confirm: '¿Estás seguro que quieres deshabilitar 2FA?',
disable_2fa_instructions: 'Ingresa tu contraseña para deshabilitar 2FA.',
@@ -107,6 +109,7 @@ const es = {
dont_allow: 'No permitir',
download: 'Descargar',
download_file: 'Descargar archivo',
downloading: 'Descargando',
email: 'Correo electrónico',
email_change_confirmation_sent:
'Se ha enviado un mensaje de confirmación a tu nueva dirección de correo electrónico. Por favor, revisa tu bandeja de entrada y sigue las instrucciónes para completar el proceso.',
@@ -196,6 +199,7 @@ const es = {
paste_into_folder: 'Pegar en la Carpeta',
path: 'Ruta',
personalization: 'Personalización',
pick_name_for_website: 'Escoge un nombre para tu página web:',
pictures: 'Imagenes',
plural_suffix: 's',
powered_by_puter_js: 'Creado por {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const fa = {
allow: 'اجازه دسترسی',
ascending: 'صعودی',
associated_websites: 'وب سایت های مرتبط',
auto_arrange: 'ترتیب خودکار',
cancel: 'لغو',
change: 'تغییر',
change_always_open_with: 'آیا می‌خواهید همیشه این نوع فایل را با ... باز کنید؟',
@@ -93,6 +94,7 @@ const fa = {
desktop: 'دسکتاپ',
desktop_background_fit: 'متناسب',
developers: 'تولیدکنندگان نرم افزار',
dir_published_as_website: '%strong% منتشر شده به:',
disable_2fa: 'غیر فعال کردن احراز هویت دو مرحله ای',
disable_2fa_confirm:
'آیا مطمئن هستید که می‌خواهید احراز هویت دو مرحله ای را غیرفعال کنید؟',
@@ -102,6 +104,7 @@ const fa = {
dont_allow: 'عدم اجازه',
download: 'دانلود',
download_file: 'دانلود فایل',
downloading: 'دانلود',
email: 'ایمیل',
email_change_confirmation_sent:
'یک ایمیل تاییدیه به آدرس ایمیل جدید شما ارسال شده است. لطفاً صندوق ایمیلهای دریافتی خود را بررسی کرده و دستورالعمل‌ها را برای تکمیل فرایند دنبال کنید.',
@@ -190,6 +193,7 @@ const fa = {
paste_into_folder: 'چسباندن در پوشه',
path: 'مسیر',
personalization: 'شخصی سازی',
pick_name_for_website: 'یک نام برای وبسایت خود انتخاب کنید:',
pictures: 'تصاویر',
plural_suffix: 'ها',
powered_by_puter_js: 'پشتیبانی شده توسط {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -37,6 +37,7 @@ const fi = {
ascending: 'Nouseva',
associated_websites: 'Tähän liittyvät verkkosivustot',
auto_arrange: 'Järjestä automaattisesti',
cancel: 'Peruuta',
change_email: 'Vaihda sähköpostiosoite',
change_language: 'Vaihda kieli',
@@ -111,6 +112,7 @@ const fi = {
desktop: 'Työpöytä',
desktop_background_fit: 'Sovita',
developers: 'Kehittäjät',
dir_published_as_website: '%strong% on julkaistu osoitteessa:',
disable_2fa: 'Ota kaksivaiheinen tunnistautuminen pois käytöstä',
disable_2fa_confirm:
'Haluatko varmasti poistaa kaksivaiheisen tunnistautumisen käytöstä?',
@@ -120,6 +122,7 @@ const fi = {
dont_allow: 'Älä salli',
download: 'Lataa',
download_file: 'Lataa tiedosto',
downloading: 'Ladataan',
email: 'Sähköpostiosoite',
email_change_confirmation_sent:
'Vahvistusviesti on lähetetty uuteen sähköpostiosoitteeseesi. Tarkista postilaatikkosi ja viimeistele prosessi seuraamalla ohjeita.',
@@ -232,6 +235,7 @@ const fi = {
paste_into_folder: 'Liitä kansioon',
path: 'Polku',
personalization: 'Personointi',
pick_name_for_website: 'Valitse nimi verkkosivustollesi:',
pictures: 'Kuvat',
plural_suffix: 't',
powered_by_puter_js: 'Palvelun tarjoaa {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const fil = {
allow: 'Payagan',
ascending: 'Pataas',
associated_websites: 'Mga kaugnay na website',
auto_arrange: 'Ayusin nang awtomatiko',
cancel: 'Kanselahin',
change_email: 'Baguhin ang email',
change_language: 'Baguhin ang wika',
@@ -89,6 +90,7 @@ const fil = {
desktop: 'Desktop',
desktop_background_fit: 'Pagkakasya',
developers: 'Mga developer',
dir_published_as_website: 'Na-publish ang %strong% sa:',
disable_2fa: 'I-disable ang 2FA',
disable_2fa_confirm: 'I-disable mo ba ang 2FA?',
disable_2fa_instructions: 'Ilagay ang password mo para i-disable ang 2FA.',
@@ -96,6 +98,7 @@ const fil = {
dont_allow: 'Huwag payagan',
download: 'I-download',
download_file: 'I-download ang file',
downloading: 'Dina-download',
email: 'Email',
email_change_confirmation_sent:
'Napadala na ang confirmation email sa bago mong email. Buksan ang inbox at sundin ang mga hakbang para matapos ang proseso.',
@@ -183,6 +186,7 @@ const fil = {
paste_into_folder: 'I-paste sa folder',
path: 'Lokasyon',
personalization: 'Personalization',
pick_name_for_website: 'Pumili ng pangalan para sa website mo:',
pictures: 'Mga larawan',
plural_suffix: '',
powered_by_puter_js: 'Pinapagana ng {{link=docs}}Puter.js{{/link}}',
+9
View File
@@ -31,6 +31,7 @@ const fr = {
allow: 'Autoriser',
ascending: 'Ascendant',
associated_websites: 'Sites associés',
auto_arrange: 'Organisation automatique',
browser: 'Navigateur',
browser_version: 'Version du navigateur',
captcha_required: 'Veuillez compléter la vérification CAPTCHA',
@@ -96,6 +97,7 @@ const fr = {
desktop: 'Bureau',
desktop_background_fit: 'Ajuster',
developers: 'Développeurs',
dir_published_as_website: '%strong% a été publié sur :',
disable_2fa: "Désactiver l'A2F",
disable_2fa_confirm: "Êtes-vous sûr de vouloir désactiver l'A2F ?",
disable_2fa_instructions: "Entrez votre mot de passe pour désactiver l'A2F.",
@@ -103,7 +105,11 @@ const fr = {
documents: 'Documents',
dont_allow: 'Ne pas autoriser',
download: 'Télécharger',
confirm_download_file_to_desktop: 'Êtes-vous sûr de vouloir télécharger %% sur votre bureau ?',
download_file: 'Télécharger le fichier',
downloading: 'Téléchargement en cours',
downloading_file: 'Téléchargement de %%',
error_download_failed: 'Échec du téléchargement du fichier',
email: 'E-mail',
email_change_confirmation_sent: 'Un e-mail de confirmation a été envoyé à votre nouvelle adresse e-mail. Veuillez vérifier votre boîte de réception et suivre les instructions pour terminer le processus.',
email_invalid: 'L\'e-mail n\'est pas valide.',
@@ -188,6 +194,7 @@ const fr = {
paste_into_folder: 'Coller dans le dossier',
path: 'Chemin',
personalization: 'Personnalisation',
pick_name_for_website: 'Choisissez un nom pour votre site internet :',
pictures: 'Images',
pixel_ratio: 'Ratio de pixels',
plural_suffix: 's',
@@ -278,6 +285,7 @@ const fr = {
untarring: 'Extraction de %strong% (.tar)',
upload: 'Importer',
uploading: 'Importation en cours',
uploading_file: 'Importation de %%',
upload_here: 'Importer ici',
uptime: 'Temps de disponibilité',
used_of: '{{used}} utilisé sur {{available}}',
@@ -453,6 +461,7 @@ const fr = {
'perm_subdomains_write': 'gérer vos sous-domaines',
'error_user_or_path_not_found': 'Utilisateur ou chemin introuvable.',
'error_invalid_username': 'Nom d\'utilisateur invalide.',
// Auth token
auth_token: 'Jeton d\'authentification',
+4
View File
@@ -31,6 +31,7 @@ const he = {
allow: 'להרשות',
ascending: 'בסדר עולה',
associated_websites: 'אתרים קשורים',
auto_arrange: 'סידור אוטומטי',
cancel: 'ביטול',
change_email: 'שינוי כתובת אימייל',
change_language: 'החלפת שפה',
@@ -90,6 +91,7 @@ const he = {
desktop: 'שולחן העבודה ',
desktop_background_fit: 'התאים',
developers: 'מפתחים',
dir_published_as_website: '%strong% פורסם ל:',
disable_2fa: 'השבתת אימות דו-שלבי',
disable_2fa_confirm: 'האם אתם בטוחים שברצונכם להשבית אימות דו-שלבי?',
disable_2fa_instructions: 'הזינו את הסיסמה שלכם כדי להשבית אימות דו-שלבי.',
@@ -97,6 +99,7 @@ const he = {
dont_allow: 'אל תאפשר',
download: 'הורדה',
download_file: 'הורדת קובץ',
downloading: 'מוריד',
email: 'אימייל',
email_change_confirmation_sent:
'אימייל אישור נשלח לכתובת האימייל החדשה שלך. אנא בדוק את תיבת הדואר הנכנס ופעל לפי ההוראות להשלמת התהליך.',
@@ -183,6 +186,7 @@ const he = {
paste_into_folder: 'הדבק בתיקיה',
path: 'מסלול',
personalization: 'התאמה אישית',
pick_name_for_website: 'בחר שם לאתר האינטרנט שלך:',
pictures: 'תמונות',
plural_suffix: 's',
powered_by_puter_js: 'מונע ע"י {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const hi = {
allow: 'अनुमति दें',
ascending: 'आरोही',
associated_websites: 'संबंधित वेबसाइटें',
auto_arrange: 'स्वचालित व्यवस्तित',
cancel: 'रद्द',
change_email: 'ई - मेल बदले',
change_language: 'भाषा बदलें',
@@ -83,6 +84,7 @@ const hi = {
desktop: 'डेस्कटॉप',
desktop_background_fit: 'उपयुक्त',
developers: 'डेवलपर्स',
dir_published_as_website: 'निर्देशिका को एक वेबसाइट के रूप में प्रकाशित किया गया है',
disable_2fa: '2एफए गायब करें',
disable_2fa_confirm: 'क्या आप वाकई 2एफए को गायब करना चाहते हैं?',
disable_2fa_instructions: '2एफए को गायब करने के लिए अपना पासवर्ड दर्ज करें।',
@@ -90,6 +92,7 @@ const hi = {
dont_allow: 'अनुमति न दें',
download: 'डाउनलोड',
download_file: 'डाउनलोड फ़ाइल',
downloading: 'डाउनलोड हो रहा है',
email: 'ईमेल',
email_change_confirmation_sent: 'आपके नए ईमेल पते पर एक पुष्टिकरण ईमेल भेज दिया गया है। कृपया अपना इनबॉक्स जांचें और प्रक्रिया पूरी करने के लिए निर्देशों का पालन करें।',
email_invalid: 'ईमेल अमान्य है।',
@@ -169,6 +172,7 @@ const hi = {
paste_into_folder: 'फ़ोल्डर में पेस्ट करें',
path: 'पथ',
personalization: 'वैयक्तिकरण',
pick_name_for_website: 'अपनी वेबसाइट के लिए एक नाम चुनें:',
pictures: 'चित्रों',
plural_suffix: 'एस',
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} द्वारा संचालित',
+4
View File
@@ -31,6 +31,7 @@ const hu = {
allow: 'Engedélyez',
ascending: 'Növekvő',
associated_websites: 'Kapcsolódó weboldalak',
auto_arrange: 'Automatikus elrendezés',
cancel: 'Mégsem',
change_email: 'Email módosítása',
change_language: 'Nyelv módosítása',
@@ -84,6 +85,7 @@ const hu = {
desktop: 'Asztal',
desktop_background_fit: 'Illesztés',
developers: 'Fejlesztők',
dir_published_as_website: '%strong% közzétéve a következő címen:',
disable_2fa: '2FA letiltása',
disable_2fa_confirm: 'Biztosan letiltod a 2FA-t?',
disable_2fa_instructions: 'Add meg a jelszavad a 2FA letiltásához.',
@@ -91,6 +93,7 @@ const hu = {
dont_allow: 'Nem engedélyez',
download: 'Letöltés',
download_file: 'Fájl letöltése',
downloading: 'Letöltés',
email: 'Email',
email_change_confirmation_sent: 'Megerősítő emailt küldtünk az új email címedre. Kérlek, ellenőrizd a postaládádat és kövesd az utasításokat a folyamat befejezéséhez.',
email_invalid: 'Érvénytelen email cím.',
@@ -170,6 +173,7 @@ const hu = {
paste_into_folder: 'Beillesztés a mappába',
path: 'Útvonal',
personalization: 'Személyre szabás',
pick_name_for_website: 'Válassz nevet a weboldaladnak:',
pictures: 'Képek',
plural_suffix: 'k',
powered_by_puter_js: 'A Puter.js hajtja',
+4
View File
@@ -31,6 +31,7 @@ const hy = {
allow: 'Թույլատրել',
ascending: 'Աճող',
associated_websites: 'Կապված կայքեր',
auto_arrange: 'Ավտոմատ դասավորել',
cancel: 'Չեղարկել',
change_email: 'Փոխել էլ. փոստը',
change_language: 'Փոխել լեզուն',
@@ -91,6 +92,7 @@ const hy = {
desktop: 'Աշխատասեղան',
desktop_background_fit: 'Հարմարեցնել',
developers: 'Ծրագրավորողներ',
dir_published_as_website: '%strong% հրապարակվել է',
disable_2fa: 'Անջատել 2FA',
disable_2fa_confirm: 'Վստա՞հ եք, որ ցանկանում եք անջատել 2FA-ն:',
disable_2fa_instructions:
@@ -99,6 +101,7 @@ const hy = {
dont_allow: 'Թույլ չտալ',
download: 'Ներբեռնել',
download_file: 'Ներբեռնել ֆայլը',
downloading: 'Ներբեռնվում է',
email: 'Էլեկտրոնային հասցե',
email_change_confirmation_sent:
'Հաստատման նամակը ուղարկվել է ձեր նոր էլ. հասցեին։ Խնդրում ենք ստուգեք ձեր փոստարկղը և հետևեք ցուցումներին՝ գործընթացը ավարտելու համար:',
@@ -189,6 +192,7 @@ const hy = {
paste_into_folder: 'Տեղադրել պանակում',
path: 'Ուղի',
personalization: 'Անհատականացում',
pick_name_for_website: 'Ընտրեք անուն ձեր կայքի համար',
pictures: 'Նկարներ',
plural_suffix: 'ներ',
powered_by_puter_js: 'Աջակցվում է {{link=docs}}Puter.js{{/link}}-ի կողմից',
+4
View File
@@ -31,6 +31,7 @@ const id = {
allow: 'Izinkan',
ascending: 'Menaik',
associated_websites: 'Situs Web Terkait',
auto_arrange: 'Atur Otomatis',
cancel: 'Batal',
change_email: 'Ubah Email',
change_language: 'Ubah Bahasa',
@@ -91,6 +92,7 @@ const id = {
desktop: 'Desktop',
desktop_background_fit: 'Cocokkan',
developers: 'Pengembang',
dir_published_as_website: '%strong% telah dipublikasikan di:',
disable_2fa: 'Nonaktifkan 2FA',
disable_2fa_confirm: 'Apakah Anda yakin ingin menonaktifkan 2FA?',
disable_2fa_instructions: 'Masukkan kata sandi Anda untuk menonaktifkan 2FA.',
@@ -98,6 +100,7 @@ const id = {
dont_allow: 'Jangan Izinkan',
download: 'Unduh',
download_file: 'Unduh File',
downloading: 'Mengunduh',
email: 'Email',
email_change_confirmation_sent:
'Email konfirmasi telah dikirim ke alamat email baru Anda. Silakan periksa kotak masuk Anda dan ikuti petunjuk untuk menyelesaikan prosesnya.',
@@ -185,6 +188,7 @@ const id = {
paste_into_folder: 'Tempel ke dalam Folder',
path: 'Lokasi',
personalization: 'Personalisasi',
pick_name_for_website: 'Pilih nama untuk situs web Anda:',
pictures: 'Gambar',
plural_suffix: 's',
powered_by_puter_js: 'Ditenagai oleh {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const ig = {
allow: 'ekwe',
ascending: 'Na-arịgo',
associated_websites: 'Weebụsaịtị Ejikọtara',
auto_arrange: 'ndokwa onwe',
cancel: 'Kagbuo',
change_email: 'ịgbanwe email',
change_language: 'ịgbanwe Asụsụ',
@@ -92,6 +93,7 @@ const ig = {
desktop: 'desktọpụ',
desktop_background_fit: 'dabara',
developers: 'Ndị mme ya',
dir_published_as_website: '%strong% e bipụtara ya:',
disable_2fa: 'Gbanyụọ 2FA',
disable_2fa_confirm: "Ị ji n'aka na ịchọrọ gbanyụọ 2FA?",
disable_2fa_instructions: 'Tinye paswọọdụ gị i ji gbanyụọ 2FA.',
@@ -99,6 +101,7 @@ const ig = {
dont_allow: 'Ekwela',
download: 'Budata',
download_file: 'Budata faịlụ',
downloading: 'Nbudata',
email: 'Email',
email_change_confirmation_sent:
'ezipula email nkwenye na adreesị ozi-e ọhụrụ gị. Biko lelee igbe mbata gị ma soro ntuziaka ka ịmechaa usoro ahụ.',
@@ -187,6 +190,7 @@ const ig = {
paste_into_folder: "Tinye n'ime nchekwa",
path: 'ụzọ',
personalization: 'Nhazi onwe',
pick_name_for_website: 'Họrọ aha maka weebụsaịtị gị:',
pictures: 'Foto',
plural_suffix: 's',
powered_by_puter_js: 'Kwadoro site na {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const it = {
allow: 'Consenti',
ascending: 'Ascendente',
associated_websites: 'Siti associati',
auto_arrange: 'Organizzazione automatica',
cancel: 'Annulla',
change_email: 'Modifica Email',
change_language: 'Cambia lingua',
@@ -92,6 +93,7 @@ const it = {
desktop: 'Scrivania',
desktop_background_fit: 'Adatta',
developers: 'Sviluppatori',
dir_published_as_website: '%strong% è stato pubblicato su:',
disable_2fa: 'Disabilita 2FA',
disable_2fa_confirm: 'Sei sicuro di voler disabilitare la 2FA?',
disable_2fa_instructions:
@@ -100,6 +102,7 @@ const it = {
dont_allow: 'Non consentire',
download: 'Scarica',
download_file: 'Scarica file',
downloading: 'Download in corso',
email: 'Email',
email_change_confirmation_sent:
"Ti abbiamo inviato un'email di conferma. Controlla la tua casella di posta e segui le istruzioni per completare il processo.",
@@ -188,6 +191,7 @@ const it = {
paste_into_folder: 'Incolla nella cartella',
path: 'Percorso',
personalization: 'Personalizzazione',
pick_name_for_website: 'Scegli un nome per il tuo sito web:',
pictures: 'Immagini',
plural_suffix: 'i',
powered_by_puter_js: 'Realizzato con {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const ja = {
allow: '許可',
ascending: '昇順',
associated_websites: '関連ウェブサイト',
auto_arrange: '自動配置',
cancel: 'キャンセル',
change_email: 'メールアドレスを変更',
change_language: '言語を変更',
@@ -84,6 +85,7 @@ const ja = {
desktop: 'デスクトップ',
desktop_background_fit: '画面背景をフィット',
developers: '開発者',
dir_published_as_website: '%strong% が公開されました:',
disable_2fa: '2FAを無効にする',
disable_2fa_confirm: '2FAを無効にしてよろしいですか?',
disable_2fa_instructions: 'パスワードを入力して2FAを無効にします。',
@@ -91,6 +93,7 @@ const ja = {
dont_allow: '許可しない',
download: 'ダウンロード',
download_file: 'ファイルをダウンロード',
downloading: 'ダウンロード中',
email: 'メール',
email_change_confirmation_sent: '確認メールが新しいメールアドレスに送信されました。受信トレイを確認し、指示に従って手続きを完了してください。',
email_invalid: 'メールアドレスが無効です。',
@@ -170,6 +173,7 @@ const ja = {
paste_into_folder: 'フォルダーに貼り付け',
path: 'パス',
personalization: 'パーソナライズ',
pick_name_for_website: 'ウェブサイトの名前を選んでください:',
pictures: '写真',
plural_suffix: '',
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} によって提供されています',
+4
View File
@@ -31,6 +31,7 @@ const ko = {
allow: '허용',
ascending: '오름차순',
associated_websites: '연결된 웹사이트',
auto_arrange: '자동 정렬',
cancel: '취소',
change_email: '이메일 변경',
change_language: '언어 변경',
@@ -93,6 +94,7 @@ const ko = {
desktop: '바탕화면',
desktop_background_fit: '맞추기',
developers: '개발자',
dir_published_as_website: '%strong%에 게시되었습니다.',
disable_2fa: '2FA 비활성화',
disable_2fa_confirm: '정말 2FA를 비활성화하시겠습니까?',
disable_2fa_instructions: '2FA를 비활성화하려면 비밀번호를 입력해주세요.',
@@ -100,6 +102,7 @@ const ko = {
dont_allow: '허용하지 않음',
download: '다운로드',
download_file: '파일 다운로드',
downloading: '다운로드 중',
email: '이메일',
email_change_confirmation_sent:
'새 이메일 주소로 확인 메일이 전송되었습니다. 받은 편지함을 확인 후 안내에 따라 절차를 완료해주세요.',
@@ -190,6 +193,7 @@ const ko = {
paste_into_folder: '폴더에 붙여넣기',
path: '경로',
personalization: '개인 설정',
pick_name_for_website: '웹사이트 이름을 선택하세요.',
pictures: '사진',
plural_suffix: '',
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} 제공',
+4
View File
@@ -31,6 +31,7 @@ const ku = {
allow: 'ڕێگەدان',
ascending: 'سەرووەی',
associated_websites: 'وێبسایتەکان پەیوەندیدار',
auto_arrange: 'ڕیزکردنی ئۆتۆماتیکی',
cancel: 'ڕەتکردنەوە',
change_email: 'گۆڕینی ئیمەیل',
change_language: 'گۆڕینی زمان',
@@ -91,6 +92,7 @@ const ku = {
desktop: 'سەروروومیزی',
desktop_background_fit: 'ڕێکخستن',
developers: 'پەرەپێدانەران',
dir_published_as_website: '%strong% بەشکراوە بۆ:',
disable_2fa: 'ناچالاککردنی 2FA',
disable_2fa_confirm: 'دڵنیایت کە ئەتەوێت 2FA ناچالاک بکەیت؟',
disable_2fa_instructions: 'وشەی تێپەڕی نووسە بۆ ناچالاککردنی 2FA.',
@@ -98,6 +100,7 @@ const ku = {
dont_allow: 'ڕێگەندان',
download: 'داگرتن',
download_file: 'داگرتنی فایل',
downloading: 'داگرتن',
email: 'ئیمەیل',
email_change_confirmation_sent:
'پەیامی پشتڕاستکردنی ئیمەیل بۆ ناونیشانی ئیمەیلە نوێتەوە نێردرا. تکایە پەیامەکانت بپشکنە و ڕێنیشانەکان پەیڕەو بکە بۆ تەواوبوونەوەی ئەم کارە.',
@@ -186,6 +189,7 @@ const ku = {
paste_into_folder: 'دانان لە فۆڵدەرەکە',
path: 'ڕێکەوت',
personalization: 'بەشداربوون',
pick_name_for_website: 'ناوێک دیاربکە بۆ وێبسایتەکەت:',
pictures: 'وێنەکان',
plural_suffix: 'کان',
powered_by_puter_js: 'پێشکەشکراوە لە {{link=docs}}پوتەر.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const ml = {
allow: 'അനുവദിക്കുക',
ascending: 'ആരോഹണം',
associated_websites: 'ബന്ധപ്പെട്ട വെബ്സൈറ്റുകൾ',
auto_arrange: 'സ്വയം ക്രമീകരിക്കുക',
cancel: 'റദ്ദാക്കുക',
change: 'മാറ്റുക',
change_always_open_with: 'ഈ തരത്തിലുള്ള ഫയൽ എപ്പോഴും ഇതുപയോഗിച്ച് തുറക്കണോ',
@@ -85,6 +86,7 @@ const ml = {
desktop: 'ഡെസ്ക്ടോപ്പ്',
desktop_background_fit: 'ഫിറ്റ്',
developers: 'ഡെവലപ്പർമാർ',
dir_published_as_website: '%strong% എന്നതിലേക്ക് പ്രസിദ്ധീകരിച്ചിരിക്കുന്നു:',
disable_2fa: '2FA പ്രവർത്തനരഹിതമാക്കുക',
disable_2fa_confirm: '2FA പ്രവർത്തനരഹിതമാക്കണമെന്ന് തീർച്ചയാണോ?',
disable_2fa_instructions: '2FA പ്രവർത്തനരഹിതമാക്കാൻ നിങ്ങളുടെ പാസ്‌വേഡ് നൽകുക.',
@@ -92,6 +94,7 @@ const ml = {
dont_allow: 'അനുവദിക്കരുത്',
download: 'ഡൗൺലോഡ്',
download_file: 'ഫയൽ ഡൗൺലോഡ് ചെയ്യുക',
downloading: 'ഡൗൺലോഡ് ചെയ്യുന്നു',
email: 'ഇമെയിൽ',
email_change_confirmation_sent: 'നിങ്ങളുടെ പുതിയ ഇമെയിൽ വിലാസത്തിലേക്ക് ഒരു സ്ഥിരീകരണ ഇമെയിൽ അയച്ചിട്ടുണ്ട്. ദയവായി നിങ്ങളുടെ ഇൻബോ',
email_invalid: 'ഇമെയിൽ അസാധുവാണ്.',
@@ -171,6 +174,7 @@ const ml = {
paste_into_folder: 'ഫോൾഡറിലേക്ക് പേസ്റ്റ് ചെയ്യുക',
path: 'പാത',
personalization: 'വ്യക്തിഗതമാക്കൽ',
pick_name_for_website: 'നിങ്ങളുടെ വെബ്സൈറ്റിന് ഒരു പേര് തിരഞ്ഞെടുക്കുക:',
pictures: 'ചിത്രങ്ങൾ',
plural_suffix: 'കൾ',
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} ഉപയോഗിച്ച് നിർമ്മിച്ചത്',
+4
View File
@@ -38,6 +38,7 @@ const my = {
allow: 'Benarkan',
ascending: 'Menaik',
associated_websites: 'Laman Web Berkaitan',
auto_arrange: 'Susunan Automatik',
cancel: 'Batal',
change: 'Ubah',
change_email: 'Tukar Emel',
@@ -104,6 +105,7 @@ const my = {
desktop: 'Desktop',
desktop_background_fit: 'Suaikan',
developers: 'Pembangun',
dir_published_as_website: '%strong% telah diterbitkan di:',
disable_2fa: 'Menyahaktifkan 2FA',
disable_2fa_confirm: 'Adakah anda yakin mahu menyahaktifkan 2FA?',
disable_2fa_instructions: 'Masukkan kata laluan anda untuk menyahaktifkan 2FA.',
@@ -111,6 +113,7 @@ const my = {
dont_allow: 'Tidak Izinkan',
download: 'Muat Naik',
download_file: 'Muat Naik Fail',
downloading: 'Memuat Naik',
email: 'Emel',
email_change_confirmation_sent:
'Emel pengesahan telah dihantar ke alamat emel baharu anda. Sila semak peti emel anda dan ikut arahan terbabit bagi menyelesaikan proses ini.',
@@ -193,6 +196,7 @@ const my = {
paste_into_folder: 'Tampal Ke Dalam Folder',
path: 'Laluan',
personalization: 'Pemperibadian',
pick_name_for_website: 'Pilih nama laman web anda:',
pictures: 'Gambar',
plural_suffix: 's',
powered_by_puter_js: 'Dikuasakan oleh {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -36,6 +36,7 @@ const nb = {
allow: 'Tillate',
ascending: 'Stigende',
associated_websites: 'Tilknyttede nettsider',
auto_arrange: 'Automatisk sortering',
cancel: 'Avbryt',
change: 'Endre',
change_always_open_with: 'Vil du alltid åpne denne filtypen med',
@@ -93,6 +94,7 @@ const nb = {
desktop: 'Skrivebord',
desktop_background_fit: 'Tilpass',
developers: 'Utviklere',
dir_published_as_website: '%strong% er publisert på:',
disable_2fa: 'Deaktiver 2FA',
disable_2fa_confirm: 'Er du sikker på at du vil deaktivere 2FA?',
disable_2fa_instructions: 'Skriv inn passordet ditt for å deaktivere 2FA.',
@@ -100,6 +102,7 @@ const nb = {
dont_allow: 'Ikke tillat',
download: 'Last ned',
download_file: 'Last ned fil',
downloading: 'Laster ned',
email: 'E-post',
email_change_confirmation_sent: 'En bekreftelses-e-post har blitt sendt til din nye e-postadresse. Sjekk innboksen din og følg instruksjonene for å fullføre prosessen.',
email_invalid: 'Ugyldig e-postadresse.',
@@ -179,6 +182,7 @@ const nb = {
paste_into_folder: 'Lim inn i mappe',
path: 'Sti',
personalization: 'Tilpasning',
pick_name_for_website: 'Velg et navn for nettstedet ditt:',
pictures: 'Bilder',
plural_suffix: '',
powered_by_puter_js: 'Drevet av {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const nl = {
allow: 'Toestaan',
ascending: 'Oplopend',
associated_websites: 'Geassocieerde Websites',
auto_arrange: 'Automatisch sorteren',
cancel: 'Annuleren',
change_email: 'E-mail Wijzigen',
change_language: 'Taal veranderen',
@@ -84,6 +85,7 @@ const nl = {
desktop: 'Bureaublad',
desktop_background_fit: 'Aanpassen',
developers: 'Ontwikkelaars',
dir_published_as_website: '%strong% is gepubliceerd op:',
disable_2fa: '2FA Uitschakelen',
disable_2fa_confirm: 'Weet u zeker dat u 2FA wilt uitschakelen?',
disable_2fa_instructions: 'Voer uw wachtwoord in om 2FA uit te schakelen.',
@@ -91,6 +93,7 @@ const nl = {
dont_allow: 'Niet Toestaan',
download: 'Downloaden',
download_file: 'Bestand Downloaden',
downloading: 'Downloaden',
email: 'E-mail',
email_change_confirmation_sent: 'Er is een bevestigingsmail gestuurd naar uw nieuwe e-mailadres. Controleer uw inbox en volg de instructies om het proces te voltooien.',
email_invalid: 'E-mail is ongeldig.',
@@ -171,6 +174,7 @@ const nl = {
paste_into_folder: 'Plakken in Map',
path: 'Pad',
personalization: 'Personalisatie',
pick_name_for_website: 'Kies een naam voor uw website',
pictures: 'Afbeeldingen',
plural_suffix: 'en',
powered_by_puter_js: 'Aangedreven door {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -49,7 +49,9 @@ const nn = {
deploy_as_app: 'Distribuer som app',
descending: 'Synkande',
desktop_background_fit: 'Tilpass',
dir_published_as_website: '%strong% er publisert på:',
download: 'Last ned',
downloading: 'Lastar ned',
email: 'E-post',
email_or_username: 'E-post eller brukarnamn',
empty_trash: 'Tøm papirkorg',
@@ -95,6 +97,7 @@ const nn = {
passwords_do_not_match: '`Nytt passord` og `Stadfest nytt passord` stemmer ikkje overeins.',
paste: 'Lim inn',
paste_into_folder: 'Lim inn i mappe',
pick_name_for_website: 'Vel eit namn for nettstaden din:',
powered_by_puter_js: 'Dreve av {{link=docs}}Puter.js{{/link}}',
preparing: 'Førebur…',
preparing_for_upload: 'Førebur opplasting…',
@@ -146,6 +149,7 @@ const nn = {
account_password: 'Stadfest kontopassord', // In English: "Verify Account Password"
allow: 'Tillat', // In English: "Allow"
associated_websites: 'Tilknytte nettstader', // In English: "Associated Websites"
auto_arrange: 'Auto-ordne', // In English: "Auto Arrange"
change: 'Endre', // In English: "Change"
change_always_open_with: 'Vil du alltid opne denne filtypen med', // In English: "Do you want to always open this type of file with"
change_email: 'Endre e-post', // In English: "Change Email"
+4
View File
@@ -31,6 +31,7 @@ const pl = {
allow: 'Pozwól',
ascending: 'Rosnąco',
associated_websites: 'Powiązane strony',
auto_arrange: 'Auto Aranżacja',
cancel: 'Anuluj',
change_email: 'Zmień email',
change_language: 'Zmień język',
@@ -84,6 +85,7 @@ const pl = {
desktop: 'Pulpit',
desktop_background_fit: 'Dopasowanie',
developers: 'Dla deweloperów',
dir_published_as_website: '%strong% został opublikowany do:',
disable_2fa: 'Wyłącz uwierzytelnianie dwuskładnikowe',
disable_2fa_confirm: 'Czy jesteś pewien że chcesz wyłączyć uwierzytelnianie dwuskładnikowe?',
disable_2fa_instructions: 'Wprowadź swoje hasło aby wyłączyć uwierzytelnianie dwuskładnikowe.',
@@ -91,6 +93,7 @@ const pl = {
dont_allow: 'Nie pozwalaj',
download: 'Pobierz',
download_file: 'Pobierz plik',
downloading: 'Pobieranie',
email: 'Email',
email_change_confirmation_sent: 'Email z potwierdzeniem został wysłany na twój adres. Sprawdź swoją skrzynkę mailową i wykonaj przesłane instrukcje aby zakończyć proces.',
email_invalid: 'Email jest nieprawidłowy.',
@@ -170,6 +173,7 @@ const pl = {
paste_into_folder: 'Wklej do folderu',
path: 'Ścieżka',
personalization: 'Personalizacja',
pick_name_for_website: 'Wybierz nazwę dla swojej strony:',
pictures: 'Obrazy',
plural_suffix: '', //In polish there is a ton of plural suffixes, so I just left it empty
powered_by_puter_js: 'Zasilane za pomocą {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const pt = {
allow: 'Permitir',
ascending: 'Ascendente',
associated_websites: 'Sites Associados',
auto_arrange: 'Auto Organizar',
cancel: 'Cancelar',
change: 'Alterar',
change_email: 'Alterar e-mail',
@@ -85,6 +86,7 @@ const pt = {
desktop: 'Desktop',
desktop_background_fit: 'Ajustado',
developers: 'Desenvolvedores',
dir_published_as_website: '%strong% foi publicado em:',
disable_2fa: 'Disabilitar 2FA',
disable_2fa_confirm: 'Tem acerteza que quer desabilitar a 2FA?',
disable_2fa_instructions: 'Submetar a sua password para desabilitar 2FA.',
@@ -92,6 +94,7 @@ const pt = {
dont_allow: 'Não Permitir',
download: 'Descarregar',
download_file: 'Descarregar Ficheiro',
downloading: 'Fazendo a descarga',
email: 'Email',
email_change_confirmation_sent: 'Um email de confirmação foi enviado para o teu novo endereço de email. Por favor, verifique a sua caixa de entrada e siga as instruções para completar o processo.',
email_invalid: 'O Email que providenciou é invalido.',
@@ -171,6 +174,7 @@ const pt = {
paste_into_folder: 'Cola na Pasta',
path: 'Caminho',
personalization: 'Personalização',
pick_name_for_website: 'Escolha um nome para seu site:',
pictures: 'Imagens',
plural_suffix: 's',
powered_by_puter_js: 'Criado com {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const ro = {
allow: 'Permite',
ascending: 'Ascendent',
associated_websites: 'Site-uri partenere',
auto_arrange: 'Aranjare automată',
cancel: 'Anulează',
change_email: 'Schimbă e-mailul',
change_language: 'Schimbă Limba',
@@ -85,6 +86,7 @@ const ro = {
desktop: 'Desktop',
desktop_background_fit: 'Potrivește fundalul',
developers: 'Programatori',
dir_published_as_website: '%strong% a fost publicat către:',
disable_2fa: 'Dezactivați 2FA',
disable_2fa_confirm: 'Sunteți sigur(ă) că doriți să dezactivați 2FA?',
disable_2fa_instructions: 'Introduceți parola dumneavoastră pentru a dezactiva 2FA.',
@@ -92,6 +94,7 @@ const ro = {
dont_allow: 'Nu permiteți',
download: 'Descarcă',
download_file: 'Descarcă fișier',
downloading: 'Se descarcă',
email: 'E-mail',
email_change_confirmation_sent: 'Un e-mail de confirmare a fost trimis către noua dumneavoastră adresă de e-mail. Vă rugăm să verificați ©ăsuța și să urmați intrucțiunile pentru a finaliza procesul.',
email_invalid: 'E-mailul este invalid.',
@@ -171,6 +174,7 @@ const ro = {
paste_into_folder: 'Inserează in folder',
path: 'Cale',
personalization: 'Personalizare',
pick_name_for_website: 'Alegeți un nume pentru site-ul dvs:',
pictures: 'Imagini',
plural_suffix: 'e',
powered_by_puter_js: 'Creat de {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const ru = {
allow: 'Разрешить',
ascending: 'По возрастанию',
associated_websites: 'Связанные сайты',
auto_arrange: 'Автоупорядочивание',
cancel: 'Отмена',
change_email: 'Изменить электронную почту',
change_language: 'Изменить язык',
@@ -93,6 +94,7 @@ const ru = {
desktop: 'Рабочий стол',
desktop_background_fit: 'Вместить',
developers: 'Разработчики',
dir_published_as_website: '%strong% опубликован в:',
disable_2fa: 'Отключить двойную аутентификацию',
disable_2fa_confirm:
'Вы уверены, что хотите отключить двойную аутентификацию?',
@@ -102,6 +104,7 @@ const ru = {
dont_allow: 'Доступ запрещён',
download: 'Загрузить',
download_file: 'Загрузить файл',
downloading: 'Загрузка',
email: 'Электронная почта',
email_change_confirmation_sent:
'На ваш новый адрес электронной почты было отправлено письмо с подтверждением. Пожалуйста, проверьте свой ящик электронной почты и следуйте инструкциям, чтобы завершить процесс.',
@@ -191,6 +194,7 @@ const ru = {
paste_into_folder: 'Вставить в папку',
path: 'Путь',
personalization: 'Персонализация',
pick_name_for_website: 'Выберите имя для вашего сайта:',
pictures: 'Изображения',
plural_suffix: 's', //does not exist in Russian language
powered_by_puter_js: 'Создано на {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const sl = {
allow: 'Dovoli',
ascending: 'Naraščajoče',
associated_websites: 'Povezana spletna mesta',
auto_arrange: 'Samodejno razporedi',
cancel: 'Prekliči',
change: 'Spremeni',
change_always_open_with: 'Ali želite to vrsto datotek vedno odpirati s/z',
@@ -88,6 +89,7 @@ const sl = {
desktop: 'Namizje',
desktop_background_fit: 'Prilagodi',
developers: 'Razvijalci',
dir_published_as_website: '%strong% je bilo objavljeno na:',
disable_2fa: 'Onemogoči 2FA',
disable_2fa_confirm: 'Ali ste prepričani, da želite onemogočiti 2FA?',
disable_2fa_instructions: 'Za onemogočanje 2FA vnesite svoje geslo.',
@@ -95,6 +97,7 @@ const sl = {
dont_allow: 'Ne dovoli',
download: 'Prenos',
download_file: 'Prenesi datoteko',
downloading: 'Prenašanje',
email: 'E-pošta',
email_change_confirmation_sent: 'Potrditveno e-poštno sporočilo je bilo poslano na vaš novi e-poštni naslov. Preverite svoj poštni predal in sledite navodilom za dokončanje postopka.',
email_invalid: 'E-pošta ni veljavna.',
@@ -176,6 +179,7 @@ const sl = {
paste_into_folder: 'Prilepi v mapo',
path: 'Pot',
personalization: 'Prilagajanje',
pick_name_for_website: 'Izberite ime za svoje spletno mesto:',
pictures: 'Slike',
plural_suffix: '', // "i", "je" - masculine; "e", "i" - feminine; "a", "i" - neuter
powered_by_puter_js: 'Poganja {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const sv = {
allow: 'Tillåt',
ascending: 'Stigande',
associated_websites: 'Anknytande webbplatser',
auto_arrange: 'Auto Arrange',
cancel: 'Avbryt',
change_email: 'Ändra e-postadress',
change_language: 'Ändra språk',
@@ -84,6 +85,7 @@ const sv = {
desktop: 'Skrivbord',
desktop_background_fit: 'Anpassa',
developers: 'Utvecklare',
dir_published_as_website: '%strong% är publicerat på:',
disable_2fa: 'Stäng av 2FA',
disable_2fa_confirm: 'Är det säkert att du vill stänga av 2FA?',
disable_2fa_instructions: 'Fyll i ditt lösenord för att stänga av 2FA.',
@@ -91,6 +93,7 @@ const sv = {
dont_allow: 'Tillåt inte',
download: 'Ladda ner',
download_file: 'Ladda ner fil',
downloading: 'Laddar ner',
email: 'E-post',
email_change_confirmation_sent: 'Ett bekräftelsemail har skickats till din nya e-postadress. Var god kontrollera inkorgen och följ instruktionerna för att fullfölja processen.',
email_invalid: 'Adressen är ogiltig.',
@@ -170,6 +173,7 @@ const sv = {
paste_into_folder: 'Klistra in i mapp',
path: 'Filväg',
personalization: 'Personalisering',
pick_name_for_website: 'Välj ett namn för din webbplats:',
pictures: 'Bilder',
plural_suffix: '', // neutrum has "", utrum has "or", "ar", "er"
powered_by_puter_js: 'Drivs av {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const ta = {
allow: 'அனுமதி',
ascending: 'ஏறுமுகம்',
associated_websites: 'தொடர்புடைய இணையதளங்கள்',
auto_arrange: 'ஆட்டோ ஏற்பாடு',
cancel: 'ரத்து செய்',
change_email: 'மின்னஞ்சலை மாற்றவும்',
change_language: 'மொழியை மாற்றவும்',
@@ -94,6 +95,7 @@ const ta = {
desktop: 'டெஸ்க்டாப்',
desktop_background_fit: 'பொருத்தம்',
developers: 'டெவலப்பர்கள்',
dir_published_as_website: '%strong% வெளியிடப்பட்டது:',
disable_2fa: '2FA ஐ முடக்கு',
disable_2fa_confirm: '2FA ஐ நிச்சயமாக முடக்க விரும்புகிறீர்களா?',
disable_2fa_instructions: '2FA ஐ முடக்க உங்கள் கடவுச்சொல்லை உள்ளிடவும்.',
@@ -101,6 +103,7 @@ const ta = {
dont_allow: 'அனுமதிக்காதே',
download: 'பதிவிறக்கவும்',
download_file: 'பதிவிறக்க கோப்பு',
downloading: 'பதிவிறக்கிறது',
email: 'மின்னஞ்சல்',
email_change_confirmation_sent:
'உங்கள் புதிய மின்னஞ்சல் முகவரிக்கு உறுதிப்படுத்தல் மின்னஞ்சல் அனுப்பப்பட்டுள்ளது. செயல்முறையை முடிக்க உங்கள் இன்பாக்ஸைச் சரிபார்த்து, வழிமுறைகளைப் பின்பற்றவும்.',
@@ -191,6 +194,7 @@ const ta = {
paste_into_folder: 'கோப்புறையில் ஒட்டவும்',
path: 'பாதை',
personalization: 'தனிப்பயனாக்கம்',
pick_name_for_website: 'உங்கள் வலைத்தளத்திற்கு ஒரு பெயரைத் தேர்ந்தெடுக்கவும்:',
pictures: 'படங்கள்',
plural_suffix: 'கள்',
powered_by_puter_js: 'மூலம் இயக்கப்படுகிறது {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const th = {
allow: 'อนุญาต',
ascending: 'เรียงจากน้อยไปมาก',
associated_websites: 'เว็บไซต์ที่เกี่ยวข้อง',
auto_arrange: 'จัดเรียงอัตโนมัติ',
cancel: 'ยกเลิก',
change_email: 'เปลี่ยนอีเมล',
change_language: 'เปลี่ยนภาษา',
@@ -83,6 +84,7 @@ const th = {
desktop: 'เดสก์ท็อป',
desktop_background_fit: 'พอดี',
developers: 'นักพัฒนา',
dir_published_as_website: '%strong% ได้รับการเผยแพร่ไปยัง:',
disable_2fa: 'ปิด 2FA',
disable_2fa_confirm: 'คุณแน่ใจว่าต้องการจะปิด 2FA?',
disable_2fa_instructions: 'กรอกรหัสผ่านเพื่อปิด 2FA.',
@@ -90,6 +92,7 @@ const th = {
dont_allow: 'ไม่อนุญาต',
download: 'ดาวน์โหลด',
download_file: 'ดาวน์โหลดไฟล์',
downloading: 'กำลังดาวน์โหลด',
email: 'อีเมล',
email_change_confirmation_sent: 'อีเมล์ยืนยันได้ถูกส่งไปที่อยู่อีเมลใหม่ของคุณแล้ว กรุณาตรวจสอบกล่องข้อความของคุณและทำตามขั้นตอนต่อจนเสร็จ',
email_invalid: 'อีเมล์ไม่ถูกต้อง.',
@@ -168,6 +171,7 @@ const th = {
paste: 'วาง',
paste_into_folder: 'วางลงในโฟลเดอร์',
personalization: 'ปรับเฉพาะบุคคล',
pick_name_for_website: 'เลือกชื่อสำหรับเว็บไซต์ของคุณ:',
pictures: 'รูปภาพ',
plural_suffix: '',
powered_by_puter_js: 'สนับสนุนโดย {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const tr = {
allow: 'İzin ver',
ascending: 'Artan',
associated_websites: 'İlişkilendirilmiş Web Siteleri',
auto_arrange: 'Otomatik Düzenle',
cancel: 'İptal',
change_email: 'E-postayı Değiştir',
change_language: 'Dili Değiştir',
@@ -83,6 +84,7 @@ const tr = {
desktop: 'Masaüstü',
desktop_background_fit: 'Sığdır',
developers: 'Geliştiriciler',
dir_published_as_website: '%strong% şu adrese yayınlandı:',
disable_2fa: 'İki faktörlü doğrulamayı kapat',
disable_2fa_confirm: 'İki faktörlü doğrulamayı kapatmak istediğinize emin misiniz?',
disable_2fa_instructions: 'İki faktörlü doğrulamayı kapatmak için parolanızı girin.',
@@ -90,6 +92,7 @@ const tr = {
dont_allow: 'İzin Verme',
download: 'İndir',
download_file: 'Dosyayı İndir',
downloading: 'İndiriliyor',
email: 'E-posta',
email_change_confirmation_sent: 'Yeni e-posta adresinize bir onay e-postası gönderilmiştir. Lütfen gelen kutunuzu kontrol edin ve işlemi tamamlamak için talimatları takip edin.',
email_invalid: 'Geçersiz E-Posta',
@@ -169,6 +172,7 @@ const tr = {
paste_into_folder: 'Klasöre Yapıştır',
path: 'Yol',
personalization: 'Kişiselleştirme',
pick_name_for_website: 'Web siteniz için bir ad seçin:',
pictures: 'Resimler',
plural_suffix: '',
powered_by_puter_js: '{{link=docs}}Puter.js{{/link}} tarafından güçlendirilmiştir',
+4
View File
@@ -31,6 +31,7 @@ const ua = {
allow: 'Дозволити',
ascending: 'За зростанням',
associated_websites: 'Асоційовані веб-сайти',
auto_arrange: 'Автоупорядкування',
cancel: 'Відміна',
change: 'Змінити',
change_email: 'Змінити Email',
@@ -85,6 +86,7 @@ const ua = {
desktop: 'Робочий стіл',
desktop_background_fit: 'Вмістити',
developers: 'Розробники',
dir_published_as_website: '%strong% опубліковано в:',
disable_2fa: 'Вимкнути двофакторну аутентифікацію',
disable_2fa_confirm: 'Ви впевнені, що хочете вимкнути двофакторну аутентифікацію?',
disable_2fa_instructions: 'Введіть ваш пароль для вимкнення двофакторної аутентифікації',
@@ -92,6 +94,7 @@ const ua = {
dont_allow: 'Не дозволяти',
download: 'Завантажити',
download_file: 'Завантажити Файл',
downloading: 'Завантажується',
email: 'Email',
email_change_confirmation_sent: 'На вашу нову електронну адресу було надіслано листа з підтвердженням. Будь ласка, перевірте свою поштову скриньку і дотримуйтесь інструкцій, щоб завершити процес.',
email_invalid: 'Електронна адреса недійсна.',
@@ -172,6 +175,7 @@ const ua = {
paste_into_folder: 'Вставити в Папку',
path: 'Шлях',
personalization: 'Персоналізація',
pick_name_for_website: "Виберіть ім'я для вашого сайту:",
pictures: 'Зображення',
plural_suffix: 's',
powered_by_puter_js: 'Створено на {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -29,6 +29,7 @@ const ur = {
add_another_account: 'دوسرا اکاؤنٹ شامل کریں',
all_fields_required: 'تمام شعبوں کی ضرورت ہے',
ascending: 'بڑھتی ہوئی',
auto_arrange: 'خودکار ترتیب',
cancel: 'منسوخ کریں',
change_email: 'ای میل تبدیل کریں',
change_language: 'زبان تبدیل کریں',
@@ -87,6 +88,7 @@ const ur = {
desktop: 'ڈیسک ٹاپ',
desktop_background_fit: 'ڈیسک ٹاپ کا پس منظر مناسب ہو',
developers: 'ڈویلپرز',
dir_published_as_website: 'ڈائریکٹری کو ویب سائٹ کے طور پر شائع کیا گیا',
disable_2fa: '2FA کو غیر فعال کریں۔',
disable_2fa_confirm: 'کیا آپ واقعی 2FA کو غیر فعال کرنا چاہتے ہیں؟',
disable_2fa_instructions:
@@ -94,6 +96,7 @@ const ur = {
documents: 'دستاویزات',
download: 'ڈاؤن لوڈ کریں ',
download_file: 'فائل ڈاؤن لوڈ کریں',
downloading: 'ڈاؤن لوڈ ہو رہا ہے ',
email: 'ای میل',
email_change_confirmation_sent:
'ایک تصدیقی ای میل آپ کے نئے ای میل پتے پر بھیج دیا گیا ہے۔ براہ کرم اپنا ان باکس چیک کریں اور عمل کو مکمل کرنے کے لیے ہدایات پر عمل کریں۔',
@@ -173,6 +176,7 @@ const ur = {
paste: 'چسپاں کریں',
paste_into_folder: 'فولڈر میں چسپاں کریں',
personalization: 'متشکل',
pick_name_for_website: 'ویب سائٹ کے لئے نام منتخب کریں ',
pictures: 'تصویریں',
plural_suffix: 'س',
powered_by_puter_js:
+4
View File
@@ -31,6 +31,7 @@ const vi = {
allow: 'Cho phép',
ascending: 'Tăng dần',
associated_websites: 'Các trang web liên kết',
auto_arrange: 'Tự động sắp xếp',
cancel: 'Hủy',
change_email: 'Thay đổi email',
change_language: 'Thay đổi ngôn ngữ',
@@ -84,6 +85,7 @@ const vi = {
desktop: 'Màn hình chính',
desktop_background_fit: 'Vừa khít',
developers: 'Nhà phát triển',
dir_published_as_website: '%strong% đã được xuất bản tại:',
disable_2fa: 'Tắt 2FA',
disable_2fa_confirm: 'Bạn có chắc chắn muốn tắt 2FA không?',
disable_2fa_instructions: 'Nhập mật khẩu của bạn để tắt 2FA.',
@@ -91,6 +93,7 @@ const vi = {
dont_allow: 'Không cho phép',
download: 'Tải xuống',
download_file: 'Tải xuống tệp',
downloading: 'Đang tải xuống',
email: 'Email',
email_change_confirmation_sent: 'Một email xác nhận đã được gửi đến địa chỉ email mới của bạn. Vui lòng kiểm tra hộp thư đến và làm theo hướng dẫn để hoàn tất quy trình.',
email_invalid: 'Email không hợp lệ.',
@@ -170,6 +173,7 @@ const vi = {
paste_into_folder: 'Dán vào thư mục',
path: 'Đường dẫn',
personalization: 'Cá nhân hóa',
pick_name_for_website: 'Chọn một tên cho trang web của bạn:',
pictures: 'Hình ảnh',
plural_suffix: '',
powered_by_puter_js: 'Được hỗ trợ bởi {{link=docs}}Puter.js{{/link}}',
+4
View File
@@ -31,6 +31,7 @@ const zh = {
allow: '允许',
ascending: '升序',
associated_websites: '相关网站',
auto_arrange: '自动排序',
cancel: '取消',
change_email: '更改邮箱',
change_language: '更改语言',
@@ -84,6 +85,7 @@ const zh = {
desktop: '桌面',
desktop_background_fit: '适合',
developers: '开发人员',
dir_published_as_website: '%strong% 已发布到:',
disable_2fa: '关闭二次认证',
disable_2fa_confirm: '您确定要关闭二次认证?',
disable_2fa_instructions: '请输入您的密码以关闭二次认证',
@@ -91,6 +93,7 @@ const zh = {
dont_allow: '操作不允许!',
download: '下载',
download_file: '下载文件',
downloading: '下载',
email: '邮箱地址',
email_change_confirmation_sent: '确认邮件已发送到您的新邮箱,请按指令完成操作',
email_invalid: '邮箱地址未验证',
@@ -170,6 +173,7 @@ const zh = {
paste_into_folder: '粘贴到文件夹',
path: '路径',
personalization: '个性化',
pick_name_for_website: '为您的网站选择一个名称:',
pictures: '图片',
plural_suffix: '',
powered_by_puter_js: '由 {{link=docs}}Puter.js{{/link}} 提供支持',
+9
View File
@@ -49,6 +49,7 @@ const zhtw = {
app_group_ungroup: '解除群組',
ascending: '升序',
associated_websites: '關聯的網站',
auto_arrange: '自動排列',
cancel: '取消',
change_email: '更改電子郵件',
change_language: '更改語言',
@@ -102,6 +103,7 @@ const zhtw = {
desktop: '桌面',
desktop_background_fit: '適應',
developers: '開發者',
dir_published_as_website: '%strong% 已發布到:',
disable_2fa: '停用雙重驗證',
disable_2fa_confirm: '您確定要停用雙重驗證嗎?',
disable_2fa_instructions: '輸入您的密碼以停用雙重驗證。',
@@ -109,6 +111,7 @@ const zhtw = {
dont_allow: '不允許',
download: '下載',
download_file: '下載檔案',
downloading: '正在下載',
email: '電子郵件',
email_change_confirmation_sent: '確認電子郵件已發送到您的新電子郵件地址。請查看您的收件匣並按照指示完成此過程。',
email_invalid: '電子郵件無效。',
@@ -188,6 +191,7 @@ const zhtw = {
paste_into_folder: '貼上到資料夾',
path: '路徑',
personalization: '個人化',
pick_name_for_website: '為您的網站選擇一個名稱:',
pictures: '圖片',
plural_suffix: '個',
powered_by_puter_js: '由 {{link=docs}}Puter.js{{/link}} 提供支援',
@@ -417,15 +421,20 @@ const zhtw = {
'window_title_pictures': "圖片", // In English: "Pictures"
'window_title_puter': "Puter", // In English: "Puter"
'window_folder_empty': "此資料夾是空的", // In English: "This folder is empty"
'confirm_download_file_to_desktop': "您確定要將 %% 下載到您的桌面嗎?", // In English: "Are you sure you want to download %% to your Desktop?"
'downloading_file': "正在下載 %%", // In English: "Downloading %%"
'error_download_failed': "下載檔案失敗", // In English: "Failed to download file"
'Resources': "資源", // In English: "Resources"
'Storage': "儲存空間", // In English: "Storage"
'untar': "解壓縮 Tar", // In English: "Untar"
'untarring': "正在解壓縮 %strong%", // In English: "Untarring %strong%"
'uploading': "正在上傳", // In English: "Uploading"
'uploading_file': "正在上傳 %%", // In English: "Uploading %%"
'tar': "Tar 壓縮", // In English: "Tar"
'download_as_tar': "下載為 Tar", // In English: "Download as Tar"
'tarring': "正在建立 Tar 壓縮檔 %strong%", // In English: "Tarring %strong%"
'error_user_or_path_not_found': "找不到使用者或路徑。", // In English: "User or path not found."
'error_invalid_username': "無效的使用者名稱。", // In English: "Invalid username."
},
};
+45 -24
View File
@@ -121,7 +121,6 @@ import {
runsUserAppTokenExchange,
} from './util/popupAuth.js';
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
import { bootsDashboard } from './helpers/bootsDashboard.js';
/**
* A share email names the account it went to. When another account is open,
@@ -136,7 +135,6 @@ const follow_share_recipient = async (action) => {
params: window.url_query_params,
action,
embedded: window.embedded_in_popup || window.is_embedded,
desktop: !window.is_dashboard_mode,
current_user: window.user,
logged_in_users: window.logged_in_users,
});
@@ -274,7 +272,15 @@ const postAuthActions = async (action) => {
// Load desktop, only if we're not embedded in a popup and not on the dashboard page
// -------------------------------------------------------------------------------------
if ( !window.embedded_in_popup && !window.is_dashboard_mode ) {
UIDesktop();
if ( window.is_fullpage_mode ) {
// In fullpage mode, skip loading desktop items and background
UIDesktop({});
} else {
await window.get_auto_arrange_data();
puter.fs.stat({ path: window.desktop_path, consistency: 'eventual' }).then(desktop_fsentry => {
UIDesktop({ desktop_fsentry: desktop_fsentry });
});
}
}
// -------------------------------------------------------------------------------------
// Dashboard mode
@@ -1253,13 +1259,17 @@ if (jQuery) {
// are we in dashboard mode?
// The dashboard is the default interface at the root path; `/dashboard` is kept as an
// alias. Direct app landings (`/app/<name>`)
// alias, and `/desktop` loads the desktop instead. Direct app landings (`/app/<name>`)
// open in the dashboard too: the app comes up maximized in-page with the dashboard
// route slotted underneath (see postAuthActions), so Back minimizes to the dashboard.
// To land the same app on the desktop instead, prefix the path:
// `/desktop/app/<name>` doesn't match the dashboard paths below, so it falls
// through to the desktop.
// URLs that carry a desktop-only flow keep booting the desktop: auth popups
// (`?embedded_in_popup=`) and iframe embeds. A share link (`?shared=`,
// (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`),
// fullpage mode (`?puter.fullpage=`), and iframe embeds. A share link (`?shared=`,
// from an email) lands in the dashboard: Files, on Shared, with the items it names
// picked out; the desktop ignores it. App metadata
// picked out; `/desktop?shared=` opens the item on the desktop instead. App metadata
// like fullpage_on_landing does NOT opt a landing out of the dashboard; it only
// affects boots that still go through the desktop flow.
{
@@ -1278,11 +1288,16 @@ if (jQuery) {
};
// note: iframe detection is inlined because globals.js (window.is_embedded) loads after this module
const in_iframe = window.location !== window.parent.location;
if (bootsDashboard({
pathname,
inIframe: in_iframe,
embeddedInPopup: !!window.embedded_in_popup,
})) {
const needs_desktop_at_root =
window.embedded_in_popup ||
in_iframe ||
search_params.has('puter.fullpage') ||
search_params.has('app') ||
search_params.has('download');
const is_dashboard_alias =
pathname === '/dashboard' || pathname === '/dashboard/';
const is_app_landing = /^\/app\/[^/]+\/?$/.test(pathname);
if (is_dashboard_alias || ((pathname === '/' || is_app_landing) && !needs_desktop_at_root)) {
window.is_dashboard_mode = true;
window.dashboard_initial_route = parseDashboardRoute();
// A share link outranks the hash: Files, on Shared, with what it names
@@ -1518,6 +1533,10 @@ window.initgui = async function (options) {
const oidcChallenge = takeOidcLoginChallenge();
const url = new URL(window.location).href;
window.url = url;
// Route segments with a leading `/desktop` dropped, so the route checks
// downstream (app landings, actions) never have to know about the prefix:
// `/desktop/app/<name>` opens the app on the desktop the same way
// `/app/<name>` opens it in the dashboard.
const url_paths = parse_url_paths(window.location.pathname);
window.url_paths = url_paths;
@@ -1663,12 +1682,10 @@ window.initgui = async function (options) {
}
//--------------------------------------------------------------------------------------
// Determine if we are in full-page mode. The dashboard always shows apps full
// page; a desktop boot (embeds, popups) can ask with ?puter.fullpage=true.
// Determine if we are in full-page mode
// i.e. https://puter.com/app/<app_name>/?puter.fullpage=true
//--------------------------------------------------------------------------------------
if (window.is_dashboard_mode) {
window.is_fullpage_mode = true;
} else if (
if (
window.url_query_params.has('puter.fullpage') &&
(window.url_query_params.get('puter.fullpage') === 'false' ||
window.url_query_params.get('puter.fullpage') === '0')
@@ -1681,6 +1698,8 @@ window.initgui = async function (options) {
) {
// Puter is in fullpage mode.
window.is_fullpage_mode = true;
} else if (window.is_dashboard_mode) {
window.is_fullpage_mode = true;
}
// Launch services before any UI is rendered
@@ -2266,8 +2285,8 @@ window.initgui = async function (options) {
sessionStorage.removeItem('puter.authme_redirect_attempted');
} catch (e) { /* sessionStorage unavailable */ }
}
// remove the token from URL, keeping the current path and hash
// (dashboard tab links like /#usage).
// remove the token from URL, keeping the current path (e.g. `/` or
// `/desktop`) and hash (dashboard tab links like /#usage).
//
// `replaceState`, not `pushState`: pushing leaves the token-bearing URL
// as the previous history entry, so Back returns to a URL containing a
@@ -2407,7 +2426,9 @@ window.initgui = async function (options) {
$('.desktop').remove();
// disable native browser exit confirmation
window.onbeforeunload = null;
window.location.replace('/');
// go back to the interface the user was in: dashboard users to the root
// dashboard, desktop users to /desktop
window.location.replace(window.is_dashboard_mode ? '/' : '/desktop');
});
$(document).ajaxError(async function (event, jqxhr) {
@@ -2577,12 +2598,12 @@ window.initgui = async function (options) {
window.update_sites_cache();
}
}
// App landing pages (`/app/<name>`) require a real account even on a first
// visit — never a temp user. So does a share link on the dashboard: a share
// only ever reaches a real account, so a temporary one could never see what
// it points at.
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
// real account even on a first visit — never a temp user. So does a share
// link: a share only ever reaches a real account, so a temporary one could
// never see what it points at.
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
const is_share_link = !!window.is_dashboard_mode && window.url_query_params.has('shared');
const is_share_link = window.url_query_params.has('shared');
const needsSignIn =
!window.is_auth() &&
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link);
+8 -22
View File
@@ -317,7 +317,7 @@ describe('kv.get GUI boot cache', () => {
result: body.args.key.map((key) => `${key}-value`),
});
await expect(kv.get('sidebar_items')).resolves.toBe('sidebar_items-value');
await expect(kv.get('window_sidebar_width')).resolves.toBe('window_sidebar_width-value');
await expect(kv.get('menubar_style')).resolves.toBe('menubar_style-value');
expect(FakeXHR.requests).toHaveLength(1);
const body = lastBody();
expect(body.method).toBe('get');
@@ -325,20 +325,6 @@ describe('kv.get GUI boot cache', () => {
expect(body.args.key).toContain('sidebar_items');
});
it('leaves keys the GUI no longer reads out of the batch', async () => {
FakeXHR.respondWith = (body) => ({
success: true,
result: body.args.key.map(() => null),
});
await kv.get('sidebar_items');
expect(lastBody().args.key).not.toContain('desktop_icons_hidden');
expect(lastBody().args.key).not.toContain('menubar_style');
expect(lastBody().args.key).not.toContain('user_preferences.clock_visible');
expect(lastBody().args.key).not.toContain('has_set_default_app_user_permissions');
expect(lastBody().args.key).not.toContain('user_preferences.auto_arrange_desktop');
expect(lastBody().args.key).not.toContain('desktop_item_positions');
});
it('a failed batch makes get(bootKey) issue a normal call', async () => {
FakeXHR.respondWith = (body) =>
Array.isArray(body.args.key)
@@ -401,24 +387,24 @@ describe('kv.get GUI boot cache', () => {
['expireAt(key, timestamp)', () => kv.expireAt('sidebar_items', 1)],
['flush()', () => kv.flush()],
])('%s makes the next get(key) read the store', async (_label, write) => {
await expect(kv.get('window_sidebar_width')).resolves.toBe('stale');
await expect(kv.get('menubar_style')).resolves.toBe('stale');
await write();
await expect(kv.get('sidebar_items')).resolves.toBe('fresh');
expect(lastBody()).toMatchObject({ method: 'get', args: { key: 'sidebar_items' } });
});
it('keeps serving the boot keys that were not written', async () => {
await kv.get('window_sidebar_width');
await kv.get('menubar_style');
await kv.set('sidebar_items', 'v');
const requests = FakeXHR.requests.length;
await expect(kv.get('window_sidebar_width')).resolves.toBe('stale');
await expect(kv.get('menubar_style')).resolves.toBe('stale');
expect(FakeXHR.requests).toHaveLength(requests);
});
it('a write before the batch is fetched still bypasses it for that key', async () => {
await kv.set('sidebar_items', 'v');
await expect(kv.get('sidebar_items')).resolves.toBe('fresh');
await expect(kv.get('window_sidebar_width')).resolves.toBe('stale');
await expect(kv.get('menubar_style')).resolves.toBe('stale');
});
it('a write issued while the batch is in flight bypasses it', async () => {
@@ -429,7 +415,7 @@ describe('kv.get GUI boot cache', () => {
});
it('a rejected write still bypasses the batch', async () => {
await kv.get('window_sidebar_width');
await kv.get('menubar_style');
FakeXHR.respondWith = (body) => body.method === 'set'
? { success: false, error: { code: 'insufficient_funds' } }
: { success: true, result: 'fresh' };
@@ -448,11 +434,11 @@ describe('kv.get boot keys outside the GUI', () => {
FakeXHR.respondWith = (body) => ({ success: true, result: `${body.args.key}-value` });
await expect(kv.get('sidebar_items')).resolves.toBe('sidebar_items-value');
await expect(kv.get('sidebar_items')).resolves.toBe('sidebar_items-value');
await expect(kv.get('window_sidebar_width')).resolves.toBe('window_sidebar_width-value');
await expect(kv.get('menubar_style')).resolves.toBe('menubar_style-value');
expect(FakeXHR.requests.map((xhr) => JSON.parse(xhr.requestBody).args)).toEqual([
{ key: 'sidebar_items' },
{ key: 'sidebar_items' },
{ key: 'window_sidebar_width' },
{ key: 'menubar_style' },
]);
},
);
@@ -4,10 +4,16 @@ import { driverCallEnvelope } from '../../../lib/networkUtils.js';
// a single batched driver call fetching all of them, so the desktop doesn't
// issue a dozen round-trips while it initializes.
const GUI_CACHE_KEYS = [
'has_set_default_app_user_permissions',
'window_sidebar_width',
'sidebar_items',
'menubar_style',
'user_preferences.auto_arrange_desktop',
'user_preferences.show_hidden_files',
'user_preferences.language',
'user_preferences.clock_visible',
'desktop_item_positions',
'desktop_icons_hidden',
];
// How long the resolved batch keeps serving reads before the cache disables
+9 -9
View File
@@ -880,9 +880,9 @@ export default suite('kv', {
// The GUI batch-reads a fixed set of keys while it boots. No runner is
// the GUI, so here those names are ordinary keys.
'get of a GUI boot key returns the stored value': async (t) => {
await t.puter.kv.set('user_preferences.show_hidden_files', 'true');
t.assert.equal(await t.puter.kv.get('user_preferences.show_hidden_files'), 'true');
t.assert.equal(await t.puter.kv.get('user_preferences.show_hidden_files'), 'true');
await t.puter.kv.set('menubar_style', 'system');
t.assert.equal(await t.puter.kv.get('menubar_style'), 'system');
t.assert.equal(await t.puter.kv.get('menubar_style'), 'system');
t.assert.equal(
await t.puter.kv.get('window_sidebar_width'),
null,
@@ -891,12 +891,12 @@ export default suite('kv', {
},
'writes to a GUI boot key are visible to the next get': async (t) => {
await t.puter.kv.set('sidebar_items', '[]');
t.assert.equal(await t.puter.kv.get('window_sidebar_width'), null);
await t.puter.kv.set('window_sidebar_width', '240');
t.assert.equal(await t.puter.kv.get('window_sidebar_width'), '240');
await t.puter.kv.del('sidebar_items');
t.assert.equal(await t.puter.kv.get('sidebar_items'), null);
await t.puter.kv.set('desktop_icons_hidden', 'true');
t.assert.equal(await t.puter.kv.get('desktop_item_positions'), null);
await t.puter.kv.set('desktop_item_positions', '{}');
t.assert.equal(await t.puter.kv.get('desktop_item_positions'), '{}');
await t.puter.kv.del('desktop_icons_hidden');
t.assert.equal(await t.puter.kv.get('desktop_icons_hidden'), null);
},
'a GUI boot key written by another client reads fresh': async (t) => {