Compare commits

...
Author SHA1 Message Date
jelveh a99198655e Open Contact Us as a dashboard dialog in the dashboard
The dashboard's Contact Us now uses the dashboard form dialog instead of a
UIWindow: a centered card on desktop and a bottom sheet on phones. Files can
be picked, dropped anywhere over the dialog, or pasted into the message, and
images show a preview. Cmd/Ctrl+Enter sends. The desktop keeps
UIWindowFeedback.
2026-11-07 00:37:55 -07:00
5 changed files with 788 additions and 11 deletions

No files matched your search

+3 -11
View File
@@ -24,10 +24,10 @@ import UIContextMenu from '../UIContextMenu.js';
import UIAlert from '../UIAlert.js';
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
import UIAuthLogin from '../Auth/UIAuthLogin.js';
import UIWindowFeedback from '../UIWindowFeedback.js';
import apply_item_added_to_containers from '../../helpers/applyItemAddedToContainers.js';
import { clear_shared_param } from '../../helpers/parseSharedPath.js';
import UIDashboardNotifications from './UIDashboardNotifications.js';
import UIDashboardContactDialog from './UIDashboardContactDialog.js';
import {
answerNotificationReceipts,
notificationSocketAcks,
@@ -900,16 +900,8 @@ async function UIDashboard (options) {
// Contact Us
{
html: i18n('contact_us'),
onClick: async function () {
UIWindowFeedback({
window_options: {
backdrop: true,
close_on_backdrop_click: true,
parent_center: true,
stay_on_top: true,
has_head: false,
},
});
onClick: function () {
UIDashboardContactDialog({ $container: $el_window, returnFocusTo: $btn.get(0) });
},
},
'-',
@@ -0,0 +1,277 @@
/*
* 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/>.
*/
// The dashboard's Contact Us form: a dashboard form dialog with screenshots
// and screen recordings attached by picking, dropping or pasting them. The
// desktop keeps UIWindowFeedback.
import {
ATTACHMENT_ACCEPT_ATTRIBUTE,
MAX_ATTACHMENTS,
MAX_ATTACHMENT_BYTES,
MAX_TOTAL_ATTACHMENT_BYTES,
checkAttachment,
} from '../../helpers/contact_attachments.js';
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import { ICONS, SVG } from './UIDashboardDialog.js';
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
const MB = 1024 * 1024;
// Keep in sync with SystemController on the backend.
const MESSAGE_MAX_LENGTH = 100_000;
// Numbers interpolated into each error message, taken from the enforced limits.
const ERROR_VALUES = {
contact_us_attachment_too_many: [MAX_ATTACHMENTS],
contact_us_attachment_too_large: [MAX_ATTACHMENT_BYTES / MB],
contact_us_attachment_total_too_large: [MAX_TOTAL_ATTACHMENT_BYTES / MB],
};
const CONTACT_ICONS = {
attach: SVG('<path d="M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/>'),
video: SVG('<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 10l6-3.5v11L16 14"/>'),
};
const t = (key, values = []) => i18n(key, values, false);
/**
* Whether a drag carries files, rather than text or a link from the page.
*
* @param {DragEvent} e
* @returns {boolean}
*/
const dragHasFiles = (e) => Array.from(e.originalEvent?.dataTransfer?.types ?? []).includes('Files');
/**
* @param {File} file
* @returns {string}
*/
const attachmentHtml = (file) => {
const name = html_encode(file.name);
// The preview's URL is let go once it has drawn; the <img> keeps the pixels.
const thumb = file.type.startsWith('image/')
? `<img src="${html_encode(URL.createObjectURL(file))}" alt="" draggable="false">`
: CONTACT_ICONS.video;
const remove = html_encode(t('contact_us_attachment_remove_named', [file.name]));
return '<li class="dashboard-contact-file">'
+ `<span class="dashboard-contact-file-thumb" aria-hidden="true">${thumb}</span>`
+ '<span class="dashboard-contact-file-text">'
+ `<span class="dashboard-contact-file-name" title="${name}">${name}</span>`
+ `<span class="dashboard-contact-file-size">${html_encode(window.byte_format(file.size))}</span>`
+ '</span>'
+ `<button type="button" class="dashboard-contact-file-remove" aria-label="${remove}" title="${i18n('contact_us_attachment_remove')}">${ICONS.close}</button>`
+ '</li>';
};
/**
* Opens the Contact Us form; the message goes to support, with any
* attachments as multipart form data. The checks here only fail fast: the
* server enforces the limits.
*
* @param {object} [opts]
* @param {jQuery} [opts.$container] Where the overlay mounts.
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
* @returns {import('./UIDashboardFormDialog.js').FormDialog}
*/
export default function UIDashboardContactDialog ({ $container, returnFocusTo } = {}) {
/** @type {File[]} */
const attachments = [];
let sending = false;
// Once sent, the form is gone and a drop or paste has nothing to join.
let sent = false;
const dialog = UIDashboardFormDialog({
$container,
returnFocusTo,
title: t('contact_us'),
intro: `<p>${i18n('feedback_c2a')}</p>`,
submitLabel: t('send'),
fields: [{
name: 'message',
type: 'textarea',
label: i18n('contact_us_message'),
placeholder: i18n('contact_us_message_placeholder'),
attrs: { rows: 6, maxlength: MESSAGE_MAX_LENGTH },
}],
async onSubmit () {
const { message } = dialog.values();
if ( ! message.trim() ) {
dialog.setStatus(t('contact_us_message_required'), { field: 'message' });
return;
}
setSending(true);
try {
const body = new FormData();
body.append('message', message);
for ( const file of attachments ) body.append('attachments', file, file.name);
// No Content-Type: the browser sets it with the boundary.
const resp = await fetch(`${window.api_origin}/contactUs`, {
method: 'POST',
headers: { Authorization: `Bearer ${window.auth_token}` },
body,
});
if ( ! resp.ok ) {
dialog.setStatus(t(resp.status === 429 ? 'contact_us_rate_limited' : 'contact_us_error'));
return;
}
sent = true;
dialog.finish({ message: t('feedback_sent_confirmation') });
} catch ( err ) {
console.error('contact-us: submit failed', err);
dialog.setStatus(t('contact_us_error'));
} finally {
setSending(false);
}
},
});
const { $dialog } = dialog;
const $overlay = $dialog.parent();
const hint_id = `${$dialog.attr('aria-labelledby')}-attach-hint`;
let h = '<div class="dashboard-contact-attachments">';
h += `<input type="file" class="dashboard-contact-file-input" accept="${html_encode(ATTACHMENT_ACCEPT_ATTRIBUTE)}" multiple hidden tabindex="-1">`;
h += `<button type="button" class="dashboard-contact-dropzone" aria-describedby="${hint_id}">`;
h += `<span class="dashboard-contact-dropzone-icon" aria-hidden="true">${CONTACT_ICONS.attach}</span>`;
h += '<span class="dashboard-contact-dropzone-text">';
h += `<span class="dashboard-contact-dropzone-label">${i18n(isTouchPrimaryDevice() ? 'contact_us_attach' : 'contact_us_attach_or_drop')}</span>`;
h += `<span class="dashboard-contact-dropzone-drop" aria-hidden="true">${i18n('contact_us_drop_to_attach')}</span>`;
h += `<span class="dashboard-contact-dropzone-hint" id="${hint_id}">${i18n('contact_us_attach_hint', [MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES / MB])}</span>`;
h += '</span>';
h += '</button>';
h += `<ul class="dashboard-contact-files" aria-label="${i18n('contact_us_attachments')}" hidden></ul>`;
h += '</div>';
$dialog.addClass('dashboard-contact-dialog')
.find('.dashboard-form-field[data-field="message"]').after(h);
const $dropzone = $dialog.find('.dashboard-contact-dropzone');
const $fileInput = $dialog.find('.dashboard-contact-file-input');
const $list = $dialog.find('.dashboard-contact-files');
const syncControls = () => {
$list.prop('hidden', attachments.length === 0);
// Nothing left to add once the count cap is reached.
$dropzone.prop('disabled', sending || attachments.length >= MAX_ATTACHMENTS);
$list.find('.dashboard-contact-file-remove').prop('disabled', sending);
};
function setSending (value) {
sending = value;
$dialog.toggleClass('dashboard-contact-sending', value);
syncControls();
}
/** @param {File[]} files */
const addFiles = (files) => {
if ( sending || sent || ! files.length ) return;
let rejection = null;
for ( const file of files ) {
const verdict = checkAttachment(file, attachments);
if ( ! verdict.ok ) {
// Report the first rejection but keep the files that fit.
rejection = rejection ?? verdict.error;
continue;
}
attachments.push(file);
const $item = $(attachmentHtml(file));
$item.find('img').one('load error', function () {
URL.revokeObjectURL(this.src);
});
$list.append($item);
}
dialog.setStatus(rejection ? t(rejection, ERROR_VALUES[rejection] ?? []) : '');
syncControls();
};
$dropzone.on('click', () => $fileInput.trigger('click'));
$fileInput.on('change', function () {
addFiles(Array.from(this.files ?? []));
// Reset so re-picking the same file fires `change` again.
this.value = '';
});
$list.on('click', '.dashboard-contact-file-remove', function () {
if ( sending ) return;
const $item = $(this).closest('.dashboard-contact-file');
const index = $list.children().index($item);
attachments.splice(index, 1);
// Focus stays in the list rather than falling back to the page.
const $next = $item.next().length ? $item.next() : $item.prev();
$item.remove();
dialog.setStatus('');
syncControls();
($next.length ? $next.find('.dashboard-contact-file-remove') : $dropzone).get(0)?.focus({ preventScroll: true });
});
// The whole overlay takes the drop, and keeps it from the window under it.
let drag_depth = 0;
const endDrag = () => {
drag_depth = 0;
$dialog.removeClass('dashboard-contact-dragging');
};
$overlay.on('dragenter', (e) => {
if ( ! dragHasFiles(e) ) return;
e.preventDefault();
e.stopPropagation();
drag_depth++;
if ( ! sending && ! sent ) $dialog.addClass('dashboard-contact-dragging');
});
$overlay.on('dragover', (e) => {
if ( ! dragHasFiles(e) ) return;
e.preventDefault();
e.stopPropagation();
e.originalEvent.dataTransfer.dropEffect = sending || sent ? 'none' : 'copy';
});
$overlay.on('dragleave', (e) => {
// Matched to the dragenters counted, whatever this one says it carries.
if ( drag_depth === 0 ) return;
e.stopPropagation();
if ( --drag_depth <= 0 ) endDrag();
});
$overlay.on('drop', (e) => {
if ( ! dragHasFiles(e) ) return;
e.preventDefault();
e.stopPropagation();
endDrag();
addFiles(Array.from(e.originalEvent.dataTransfer.files ?? []));
});
// A screenshot pasted into the message is attached to it.
$dialog.on('paste', '.dashboard-form-input[name="message"]', (e) => {
const clipboard = e.originalEvent?.clipboardData;
const files = Array.from(clipboard?.files ?? []);
if ( ! files.length ) return;
// Some apps put the image's name or markup alongside it; that text still pastes.
if ( ! clipboard.getData('text/plain') ) e.preventDefault();
addFiles(files);
});
// Enter alone is a new line in the message; with Cmd or Ctrl, it sends.
$dialog.on('keydown', '.dashboard-form-input[name="message"]', (e) => {
if ( e.key !== 'Enter' || ! (e.metaKey || e.ctrlKey) ) return;
e.preventDefault();
$dialog.trigger('submit');
});
syncControls();
return dialog;
}
@@ -0,0 +1,255 @@
// @vitest-environment jsdom
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
import { MAX_ATTACHMENTS } from '../../helpers/contact_attachments.js';
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = (key, values = []) => [key, ...values].join(' ');
globalThis.html_encode = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
window.html_encode = globalThis.html_encode;
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
window.matchMedia = () => ({ matches: false });
window.byte_format = (bytes) => `${bytes} B`;
URL.createObjectURL = vi.fn(() => 'blob:preview');
URL.revokeObjectURL = vi.fn();
const { default: UIDashboardContactDialog } = await import('./UIDashboardContactDialog.js');
const settle = async () => {
for ( let i = 0; i < 10; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
};
const message = () => $('.dashboard-form-input[name="message"]');
const status = () => $('.dashboard-form-status').text();
const fileNames = () => $('.dashboard-contact-file-name').map((_, el) => el.textContent).get();
const png = (name = 'shot.png', size = 1024) => new File([new Uint8Array(size)], name, { type: 'image/png' });
const mp4 = (name = 'clip.mp4') => new File([new Uint8Array(2048)], name, { type: 'video/mp4' });
/** Picks files through the hidden file input, as the system picker would. */
const pick = (...files) => {
const input = $('.dashboard-contact-file-input').get(0);
Object.defineProperty(input, 'files', { value: files, configurable: true });
$(input).trigger('change');
};
/** A native-event stand-in for jQuery to wrap, carrying `fields`. */
const nativeEvent = (fields) => ({ preventDefault () {}, stopPropagation () {}, ...fields });
/** A file drag event carrying `files`, as the browser sends it. */
const fileDrag = (type, files = []) => $.Event(type, {
originalEvent: nativeEvent({ dataTransfer: { types: ['Files'], files, dropEffect: 'none' } }),
});
const submit = async () => {
$('.dashboard-form-dialog').trigger('submit');
await settle();
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.fetch = vi.fn(async () => new Response('{}', { status: 200 }));
window.api_origin = 'https://api.puter.test';
window.auth_token = 'tok-1';
});
describe('Contact Us dialog', () => {
it('opens as a dashboard form dialog with no attachments listed', () => {
UIDashboardContactDialog();
expect($('.dashboard-dialog-overlay .dashboard-contact-dialog').length).toBe(1);
expect(message().length).toBe(1);
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
});
it('asks for a message before sending', async () => {
UIDashboardContactDialog();
message().val(' ');
await submit();
expect(fetch).not.toHaveBeenCalled();
expect(status()).toBe('contact_us_message_required');
expect(message().attr('aria-invalid')).toBe('true');
});
it('posts the message and attachments as multipart and confirms', async () => {
UIDashboardContactDialog();
pick(png(), mp4());
message().val('It broke');
await submit();
const [url, init] = fetch.mock.calls[0];
expect(url).toBe('https://api.puter.test/contactUs');
expect(init.headers).toEqual({ Authorization: 'Bearer tok-1' });
expect(init.body.get('message')).toBe('It broke');
expect(init.body.getAll('attachments').map(f => f.name)).toEqual(['shot.png', 'clip.mp4']);
expect($('.dashboard-form-done').text()).toContain('feedback_sent_confirmation');
expect($('.dashboard-contact-attachments').length).toBe(0);
});
it('says when the sender is rate limited and keeps the form', async () => {
fetch.mockResolvedValueOnce(new Response('{}', { status: 429 }));
UIDashboardContactDialog();
message().val('Hello');
await submit();
expect(status()).toBe('contact_us_rate_limited');
expect(message().val()).toBe('Hello');
expect(message().prop('disabled')).toBe(false);
});
it('shows a generic error when the request fails', async () => {
fetch.mockRejectedValueOnce(new TypeError('offline'));
vi.spyOn(console, 'error').mockImplementation(() => {});
UIDashboardContactDialog();
message().val('Hello');
await submit();
expect(status()).toBe('contact_us_error');
});
it('locks the attachments while sending', async () => {
let respond;
fetch.mockReturnValueOnce(new Promise(resolve => {
respond = resolve;
}));
UIDashboardContactDialog();
pick(png());
message().val('Hello');
$('.dashboard-form-dialog').trigger('submit');
await settle();
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(true);
pick(png('late.png'));
expect(fileNames()).toEqual(['shot.png']);
respond(new Response('{}', { status: 500 }));
await settle();
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(false);
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(false);
});
});
describe('attachments', () => {
it('lists picked files with a preview for images', () => {
UIDashboardContactDialog();
pick(png('a.png'), mp4('b.mp4'));
expect(fileNames()).toEqual(['a.png', 'b.mp4']);
expect($('.dashboard-contact-files').prop('hidden')).toBe(false);
expect($('.dashboard-contact-file').eq(0).find('img').attr('src')).toBe('blob:preview');
expect($('.dashboard-contact-file').eq(1).find('img').length).toBe(0);
expect($('.dashboard-contact-file-remove').eq(0).attr('aria-label')).toBe('contact_us_attachment_remove_named a.png');
});
it('keeps the files that fit and reports the first rejection', () => {
UIDashboardContactDialog();
const text = new File(['x'], 'notes.txt', { type: 'text/plain' });
pick(png('a.png'), text, png('b.png'));
expect(fileNames()).toEqual(['a.png', 'b.png']);
expect(status()).toBe('contact_us_attachment_unsupported');
});
it('stops offering to attach once the count cap is reached', () => {
UIDashboardContactDialog();
pick(...Array.from({ length: MAX_ATTACHMENTS }, (_, i) => png(`${i}.png`)));
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
pick(png('extra.png'));
expect(fileNames()).toHaveLength(MAX_ATTACHMENTS);
expect(status()).toBe(`contact_us_attachment_too_many ${MAX_ATTACHMENTS}`);
});
it('removes the file whose button is pressed and moves focus to the next one', async () => {
UIDashboardContactDialog();
pick(png('a.png'), png('b.png'), png('c.png'));
$('.dashboard-contact-file-remove').eq(1).trigger('click');
expect(fileNames()).toEqual(['a.png', 'c.png']);
expect(document.activeElement).toBe($('.dashboard-contact-file-remove').get(1));
message().val('Hello');
await submit();
expect(fetch.mock.calls[0][1].body.getAll('attachments').map(f => f.name)).toEqual(['a.png', 'c.png']);
});
it('returns focus to the attach button when the last file is removed', () => {
UIDashboardContactDialog();
pick(png());
$('.dashboard-contact-file-remove').trigger('click');
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
expect(document.activeElement).toBe($('.dashboard-contact-dropzone').get(0));
});
it('takes files dropped anywhere on the overlay, without letting the drop through', () => {
const onWindowDrop = vi.fn();
const $win = $('<div class="window"></div>').appendTo('body').on('drop', onWindowDrop);
UIDashboardContactDialog({ $container: $win });
const $overlay = $('.dashboard-dialog-overlay');
$overlay.trigger(fileDrag('dragenter'));
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
const drop = fileDrag('drop', [png('dropped.png')]);
$overlay.trigger(drop);
expect(drop.isDefaultPrevented()).toBe(true);
expect(onWindowDrop).not.toHaveBeenCalled();
expect(fileNames()).toEqual(['dropped.png']);
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
});
it('clears the drop highlight when the drag leaves', () => {
UIDashboardContactDialog();
const $overlay = $('.dashboard-dialog-overlay');
$overlay.trigger(fileDrag('dragenter'));
$overlay.find('.dashboard-contact-dropzone').trigger(fileDrag('dragenter'));
$overlay.find('.dashboard-contact-dropzone').trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
$overlay.trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
});
it('ignores drags that carry no files', () => {
UIDashboardContactDialog();
const drag = $.Event('dragenter', { originalEvent: nativeEvent({ dataTransfer: { types: ['text/plain'] } }) });
$('.dashboard-dialog-overlay').trigger(drag);
expect(drag.isDefaultPrevented()).toBe(false);
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
});
it('attaches an image pasted into the message', () => {
UIDashboardContactDialog();
const paste = $.Event('paste', {
originalEvent: nativeEvent({ clipboardData: { files: [png('pasted.png')], getData: () => '' } }),
});
message().trigger(paste);
expect(fileNames()).toEqual(['pasted.png']);
expect(paste.isDefaultPrevented()).toBe(true);
});
});
describe('keyboard', () => {
it('sends on Cmd/Ctrl+Enter but not on Enter alone', async () => {
UIDashboardContactDialog();
message().val('Hello');
message().trigger($.Event('keydown', { key: 'Enter' }));
await settle();
expect(fetch).not.toHaveBeenCalled();
message().trigger($.Event('keydown', { key: 'Enter', ctrlKey: true }));
await settle();
expect(fetch).toHaveBeenCalledTimes(1);
});
});
+247
View File
@@ -10590,6 +10590,253 @@ body.dashboard-mode .notifications-close-all {
overflow-wrap: anywhere;
}
/* Contact Us: a message with screenshots or recordings attached */
.dashboard-dialog.dashboard-contact-dialog {
width: min(500px, 100%);
}
.dashboard-contact-dialog .dashboard-form-input[name='message'] {
min-height: 132px;
}
.dashboard-contact-attachments {
display: flex;
flex-direction: column;
gap: 8px;
}
.dashboard-contact-dropzone {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin: 0;
padding: 12px 14px;
border: 1px dashed var(--dashboard-border);
border-radius: 10px;
background: transparent;
color: var(--dashboard-text-primary);
font: inherit;
text-align: start;
cursor: pointer;
box-sizing: border-box;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.dashboard-contact-dropzone-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--select-tint);
color: var(--select-color);
}
.dashboard-contact-dropzone-icon svg {
width: 17px;
height: 17px;
}
.dashboard-contact-dropzone-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dashboard-contact-dropzone-label,
.dashboard-contact-dropzone-drop {
font-size: 13px;
font-weight: 600;
}
.dashboard-contact-dropzone-drop {
display: none;
color: var(--select-text);
}
.dashboard-contact-dropzone-hint {
font-size: 12px;
line-height: 1.4;
color: var(--dashboard-text-muted);
}
@media (hover: hover) {
.dashboard-contact-dropzone:hover:not(:disabled) {
border-color: var(--select-color);
background: var(--select-tint-weak);
}
}
.dashboard-contact-dropzone:focus-visible {
outline: none;
border-color: var(--select-color);
box-shadow: 0 0 0 3px var(--select-ring);
}
.dashboard-contact-dropzone:disabled {
cursor: default;
opacity: 0.6;
}
/* Files dragged anywhere over the dialog land here. */
.dashboard-contact-dragging .dashboard-contact-dropzone {
border-style: solid;
border-color: var(--select-color);
background: var(--select-tint);
box-shadow: 0 0 0 3px var(--select-ring);
}
.dashboard-contact-dragging .dashboard-contact-dropzone-label {
display: none;
}
.dashboard-contact-dragging .dashboard-contact-dropzone-drop {
display: block;
}
.dashboard-contact-files {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-contact-files[hidden] {
display: none;
}
.dashboard-contact-file {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 6px;
border: 1px solid var(--dashboard-border);
border-radius: 10px;
background: var(--dashboard-input-background);
animation: dashboard-contact-file-in 160ms ease;
}
@keyframes dashboard-contact-file-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.dashboard-contact-file-thumb {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 7px;
overflow: hidden;
background: var(--dashboard-sidebar-background);
color: var(--dashboard-text-secondary);
}
.dashboard-contact-file-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.dashboard-contact-file-thumb svg {
width: 20px;
height: 20px;
}
.dashboard-contact-file-text {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.dashboard-contact-file-name {
overflow: hidden;
font-size: 13px;
font-weight: 500;
color: var(--dashboard-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-contact-file-size {
font-size: 12px;
color: var(--dashboard-text-muted);
font-variant-numeric: tabular-nums;
}
.dashboard-contact-file-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dashboard-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.dashboard-contact-file-remove svg {
width: 16px;
height: 16px;
}
@media (hover: hover) {
.dashboard-contact-file-remove:hover:not(:disabled) {
background: var(--dashboard-hover);
color: var(--dashboard-text-primary);
}
}
.dashboard-contact-file-remove:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--select-ring);
}
.dashboard-contact-file-remove:disabled {
cursor: default;
opacity: 0.5;
}
/* Sending: the files are on their way and no longer up for change. */
.dashboard-contact-sending .dashboard-contact-file {
opacity: 0.7;
}
@media (pointer: coarse) {
.dashboard-contact-dropzone {
min-height: 56px;
}
.dashboard-contact-file-remove {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-contact-file {
animation: none;
}
}
/* API token, shown once */
.dashboard-token-warning {
padding: 10px 12px;
+6
View File
@@ -129,13 +129,19 @@ const en = {
choose_publishing_option: 'Choose how you want to publish your website:',
contact_us: 'Contact Us',
contact_us_attach: 'Attach Files',
contact_us_attach_or_drop: 'Attach files or drop them here',
contact_us_attach_hint: 'Images and videos — up to %% files, %% MB each.',
contact_us_attachment_remove: 'Remove attachment',
contact_us_attachment_remove_named: 'Remove %%',
contact_us_attachment_too_large: 'That file is too large. Each attachment can be up to %% MB.',
contact_us_attachment_too_many: 'You can attach up to %% files.',
contact_us_attachment_total_too_large: 'Those files add up to more than %% MB. Try attaching fewer of them, or smaller ones.',
contact_us_attachments: 'Attachments',
contact_us_attachment_unsupported: 'That file type is not supported. Attach an image (PNG, JPEG, GIF, WebP) or a video (MP4, MOV, WebM).',
contact_us_drop_to_attach: 'Drop to attach',
contact_us_error: 'Something went wrong. Please try again.',
contact_us_message: 'Message',
contact_us_message_placeholder: 'How can we help? For a bug, the steps that lead to it help us most.',
contact_us_message_required: 'Please write a message before sending.',
contact_us_rate_limited: 'You have sent a lot of messages recently. Please try again later.',
contact_us_verification_required: 'You must have a verified email address to use this.',