mirror of https://github.com/go-gitea/gitea.git
Allow cropping an avatar before setting it (#32565)
Provide a cropping tool on the avatar editing page, allowing users to select the cropping area themselves. This way, users can decide the displayed area of the image, rather than us deciding for them. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
parent
f1bea3c3b8
commit
68d9f36543
|
@ -765,6 +765,7 @@ uploaded_avatar_not_a_image = The uploaded file is not an image.
|
|||
uploaded_avatar_is_too_big = The uploaded file size (%d KiB) exceeds the maximum size (%d KiB).
|
||||
update_avatar_success = Your avatar has been updated.
|
||||
update_user_avatar_success = The user's avatar has been updated.
|
||||
cropper_prompt = You can edit the image before saving. The edited image will be saved as PNG.
|
||||
|
||||
change_password = Update Password
|
||||
old_password = Current Password
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"clippie": "4.1.3",
|
||||
"cropperjs": "1.6.2",
|
||||
"css-loader": "7.1.2",
|
||||
"dayjs": "1.11.13",
|
||||
"dropzone": "6.0.0-beta.2",
|
||||
|
@ -6876,6 +6877,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"clippie": "4.1.3",
|
||||
"cropperjs": "1.6.2",
|
||||
"css-loader": "7.1.2",
|
||||
"dayjs": "1.11.13",
|
||||
"dropzone": "6.0.0-beta.2",
|
||||
|
|
|
@ -127,6 +127,11 @@
|
|||
<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
|
||||
</div>
|
||||
|
||||
<div class="field tw-pl-4 cropper-panel tw-hidden">
|
||||
<div>{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
|
||||
<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button>
|
||||
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
@import "cropperjs/dist/cropper.css";
|
||||
|
||||
.page-content.user.profile .cropper-panel .cropper-wrapper {
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
|
@ -40,6 +40,7 @@
|
|||
@import "./features/codeeditor.css";
|
||||
@import "./features/projects.css";
|
||||
@import "./features/tribute.css";
|
||||
@import "./features/cropper.css";
|
||||
@import "./features/console.css";
|
||||
|
||||
@import "./markup/content.css";
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import {showElem} from '../../utils/dom.ts';
|
||||
|
||||
type CropperOpts = {
|
||||
container: HTMLElement,
|
||||
imageSource: HTMLImageElement,
|
||||
fileInput: HTMLInputElement,
|
||||
}
|
||||
|
||||
export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
|
||||
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
|
||||
let currentFileName = '';
|
||||
let currentFileLastModified = 0;
|
||||
const cropper = new Cropper(imageSource, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 2,
|
||||
autoCrop: false,
|
||||
crop() {
|
||||
const canvas = cropper.getCroppedCanvas();
|
||||
canvas.toBlob((blob) => {
|
||||
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
|
||||
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(croppedFile);
|
||||
fileInput.files = dataTransfer.files;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
|
||||
const files = e.target.files;
|
||||
if (files?.length > 0) {
|
||||
currentFileName = files[0].name;
|
||||
currentFileLastModified = files[0].lastModified;
|
||||
const fileURL = URL.createObjectURL(files[0]);
|
||||
imageSource.src = fileURL;
|
||||
cropper.replace(fileURL);
|
||||
showElem(container);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {initRepoBranchesSettings} from './repo-settings-branches.ts';
|
||||
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
|
||||
|
@ -31,7 +31,7 @@ describe('Repository Branch Settings', () => {
|
|||
});
|
||||
|
||||
test('should initialize sortable for protected branches list', () => {
|
||||
initRepoBranchesSettings();
|
||||
initRepoSettingsBranchesDrag();
|
||||
|
||||
expect(createSortable).toHaveBeenCalledWith(
|
||||
document.querySelector('#protected-branches-list'),
|
||||
|
@ -45,7 +45,7 @@ describe('Repository Branch Settings', () => {
|
|||
test('should not initialize if protected branches list is not present', () => {
|
||||
document.body.innerHTML = '';
|
||||
|
||||
initRepoBranchesSettings();
|
||||
initRepoSettingsBranchesDrag();
|
||||
|
||||
expect(createSortable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@ -59,7 +59,7 @@ describe('Repository Branch Settings', () => {
|
|||
return {destroy: vi.fn()};
|
||||
});
|
||||
|
||||
initRepoBranchesSettings();
|
||||
initRepoSettingsBranchesDrag();
|
||||
|
||||
expect(POST).toHaveBeenCalledWith(
|
||||
'some/repo/branches/priority',
|
||||
|
|
|
@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
|
|||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemChildren} from '../utils/dom.ts';
|
||||
|
||||
export function initRepoBranchesSettings() {
|
||||
export function initRepoSettingsBranchesDrag() {
|
||||
const protectedBranchesList = document.querySelector('#protected-branches-list');
|
||||
if (!protectedBranchesList) return;
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import {minimatch} from 'minimatch';
|
|||
import {createMonaco} from './codeeditor.ts';
|
||||
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {initRepoBranchesSettings} from './repo-settings-branches.ts';
|
||||
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
|
||||
|
||||
const {appSubUrl, csrfToken} = window.config;
|
||||
|
||||
|
@ -155,5 +155,5 @@ export function initRepoSettings() {
|
|||
initRepoSettingsCollaboration();
|
||||
initRepoSettingsSearchTeamBox();
|
||||
initRepoSettingsGitHook();
|
||||
initRepoBranchesSettings();
|
||||
initRepoSettingsBranchesDrag();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {initCompCropper} from './comp/Cropper.ts';
|
||||
|
||||
function initUserSettingsAvatarCropper() {
|
||||
const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
|
||||
const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
|
||||
const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
|
||||
initCompCropper({container, fileInput, imageSource});
|
||||
}
|
||||
|
||||
export function initUserSettings() {
|
||||
if (!document.querySelectorAll('.user.settings.profile').length) return;
|
||||
if (!document.querySelector('.user.settings.profile')) return;
|
||||
|
||||
initUserSettingsAvatarCropper();
|
||||
|
||||
const usernameInput = document.querySelector('#username');
|
||||
if (!usernameInput) return;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type {SortableOptions, SortableEvent} from 'sortablejs';
|
||||
|
||||
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) {
|
||||
export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}) {
|
||||
// @ts-expect-error: wrong type derived by typescript
|
||||
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');
|
||||
|
||||
|
|
Loading…
Reference in New Issue