Initial commit: Web packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit cd458d4b85
791 changed files with 410049 additions and 0 deletions

View file

@ -0,0 +1,282 @@
/** @odoo-module **/
import { patch } from 'web.utils';
import { KeepLast } from "@web/core/utils/concurrency";
import { MediaDialog, TABS } from '@web_editor/components/media_dialog/media_dialog';
import { ImageSelector } from '@web_editor/components/media_dialog/image_selector';
import { useService } from '@web/core/utils/hooks';
import { uploadService, AUTOCLOSE_DELAY } from '@web_editor/components/upload_progress_toast/upload_service';
import { useState, Component } from "@odoo/owl";
class UnsplashCredentials extends Component {
setup() {
this.state = useState({
key: '',
appId: '',
hasKeyError: this.props.hasCredentialsError,
hasAppIdError: this.props.hasCredentialsError,
});
}
submitCredentials() {
if (this.state.key === '') {
this.state.hasKeyError = true;
} else if (this.state.appId === '') {
this.state.hasAppIdError = true;
} else {
this.props.submitCredentials(this.state.key, this.state.appId);
}
}
}
UnsplashCredentials.template = 'web_unsplash.UnsplashCredentials';
export class UnsplashError extends Component {}
UnsplashError.template = 'web_unsplash.UnsplashError';
UnsplashError.components = {
UnsplashCredentials,
};
patch(ImageSelector.prototype, 'image_selector_unsplash', {
setup() {
this._super();
this.unsplash = useService('unsplash');
this.keepLastUnsplash = new KeepLast();
this.state.unsplashRecords = [];
this.state.isFetchingUnsplash = false;
this.state.isMaxed = false;
this.state.unsplashError = null;
this.state.useUnsplash = true;
this.NUMBER_OF_RECORDS_TO_DISPLAY = 30;
this.errorMessages = {
'key_not_found': {
title: this.env._t("Setup Unsplash to access royalty free photos."),
subtitle: "",
},
401: {
title: this.env._t("Unauthorized Key"),
subtitle: this.env._t("Please check your Unsplash access key and application ID."),
},
403: {
title: this.env._t("Search is temporarily unavailable"),
subtitle: this.env._t("The max number of searches is exceeded. Please retry in an hour or extend to a better account."),
},
};
},
get canLoadMore() {
if (this.state.searchService === 'all') {
return this._super() || this.state.needle && !this.state.isMaxed && !this.state.unsplashError;
} else if (this.state.searchService === 'unsplash') {
return this.state.needle && !this.state.isMaxed && !this.state.unsplashError;
}
return this._super();
},
get hasContent() {
if (this.state.searchService === 'all') {
return this._super() || !!this.state.unsplashRecords.length;
} else if (this.state.searchService === 'unsplash') {
return !!this.state.unsplashRecords.length;
}
return this._super();
},
get errorTitle() {
if (this.errorMessages[this.state.unsplashError]) {
return this.errorMessages[this.state.unsplashError].title;
}
return this.env._t("Something went wrong");
},
get errorSubtitle() {
if (this.errorMessages[this.state.unsplashError]) {
return this.errorMessages[this.state.unsplashError].subtitle;
}
return this.env._t("Please check your internet connection or contact administrator.");
},
get selectedRecordIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'unsplashRecord').map(({ id }) => id);
},
get isFetching() {
return this._super() || this.state.isFetchingUnsplash;
},
// It seems that setters are mandatory when patching a component that
// extends another component.
set canLoadMore(_) {},
set hasContent(_) {},
set isFetching(_) {},
set selectedMediaIds(_) {},
set attachmentsDomain(_) {},
set errorTitle(_) {},
set errorSubtitle(_) {},
set selectedRecordIds(_) {},
async fetchUnsplashRecords(offset) {
if (!this.state.needle) {
return { records: [], isMaxed: false };
}
this.state.isFetchingUnsplash = true;
try {
const { isMaxed, images } = await this.unsplash.getImages(this.state.needle, offset, this.NUMBER_OF_RECORDS_TO_DISPLAY, this.props.orientation);
this.state.isFetchingUnsplash = false;
this.state.unsplashError = false;
// Ignore duplicates.
const existingIds = this.state.unsplashRecords.map(existing => existing.id);
const newImages = images.filter(record => !existingIds.includes(record.id));
const records = newImages.map(record => {
const url = new URL(record.urls.regular);
// In small windows, row height could get quite a bit larger than the min, so we keep some leeway.
url.searchParams.set('h', 2 * this.MIN_ROW_HEIGHT);
url.searchParams.delete('w');
return Object.assign({}, record, {
url: url.toString(),
mediaType: 'unsplashRecord',
});
});
return { isMaxed, records };
} catch (e) {
this.state.isFetchingUnsplash = false;
if (e === 'no_access') {
this.state.useUnsplash = false;
} else {
this.state.unsplashError = e;
}
return { records: [], isMaxed: true };
}
},
async loadMore(...args) {
await this._super(...args);
return this.keepLastUnsplash.add(this.fetchUnsplashRecords(this.state.unsplashRecords.length)).then(({ records, isMaxed }) => {
// This is never reached if another search or loadMore occurred.
this.state.unsplashRecords.push(...records);
this.state.isMaxed = isMaxed;
});
},
async search(...args) {
await this._super(...args);
await this.searchUnsplash();
},
async searchUnsplash() {
if (!this.state.needle) {
this.state.unsplashError = false;
this.state.unsplashRecords = [];
this.state.isMaxed = false;
}
return this.keepLastUnsplash.add(this.fetchUnsplashRecords(0)).then(({ records, isMaxed }) => {
// This is never reached if a new search occurred.
this.state.unsplashRecords = records;
this.state.isMaxed = isMaxed;
});
},
async onClickRecord(media) {
this.props.selectMedia({ ...media, mediaType: 'unsplashRecord', query: this.state.needle });
if (!this.props.multiSelect) {
await this.props.save();
}
},
async submitCredentials(key, appId) {
this.state.unsplashError = null;
await this.rpc('/web_unsplash/save_unsplash', { key, appId });
await this.searchUnsplash();
},
});
ImageSelector.components = {
...ImageSelector.components,
UnsplashError,
};
patch(MediaDialog.prototype, 'media_dialog_unsplash', {
setup() {
this._super();
this.uploadService = useService('upload');
},
async save() {
const _super = this._super.bind(this);
const selectedImages = this.selectedMedia[TABS.IMAGES.id];
if (selectedImages) {
const unsplashRecords = selectedImages.filter(media => media.mediaType === 'unsplashRecord');
if (unsplashRecords.length) {
await this.uploadService.uploadUnsplashRecords(unsplashRecords, { resModel: this.props.resModel, resId: this.props.resId }, (attachments) => {
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[TABS.IMAGES.id].filter(media => media.mediaType !== 'unsplashRecord');
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[TABS.IMAGES.id].concat(attachments.map(attachment => ({...attachment, mediaType: 'attachment'})));
});
}
}
return _super(...arguments);
},
});
patch(uploadService, 'upload_service_unsplash', {
start(env, { rpc }) {
const service = this._super(...arguments);
return {
...service,
async uploadUnsplashRecords(records, { resModel, resId }, onUploaded) {
service.incrementId();
const file = service.addFile({
id: service.fileId,
name: records.length > 1 ?
_.str.sprintf(env._t("Uploading %s '%s' images."), records.length, records[0].query) :
_.str.sprintf(env._t("Uploading '%s' image."), records[0].query),
size: null,
progress: 0,
});
try {
const urls = {};
for (const record of records) {
const _1920Url = new URL(record.urls.regular);
_1920Url.searchParams.set('w', '1920');
urls[record.id] = {
url: _1920Url.href,
download_url: record.links.download_location,
description: record.alt_description,
};
}
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', ev => {
const rpcComplete = ev.loaded / ev.total * 100;
file.progress = rpcComplete;
});
xhr.upload.addEventListener('load', function () {
// Don't show yet success as backend code only starts now
file.progress = 100;
});
const attachments = await rpc('/web_unsplash/attachment/add', {
'res_id': resId,
'res_model': resModel,
'unsplashurls': urls,
'query': records[0].query,
}, {xhr});
if (attachments.error) {
file.hasError = true;
file.errorMessage = attachments.error;
} else {
file.uploaded = true;
await onUploaded(attachments);
}
setTimeout(() => service.deleteFile(file.id), AUTOCLOSE_DELAY);
} catch (error) {
file.hasError = true;
setTimeout(() => service.deleteFile(file.id), AUTOCLOSE_DELAY);
throw error;
}
}
};
}
});

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_unsplash.UnsplashError" owl="1">
<div class="alert alert-info w-100">
<h4><t t-esc="props.title"/></h4>
<p><t t-esc="props.subtitle"/></p>
<UnsplashCredentials t-if="props.showCredentials" submitCredentials="props.submitCredentials" hasCredentialsError="props.hasCredentialsError"/>
</div>
</t>
<t t-name="web_unsplash.UnsplashCredentials" owl="1">
<div class="d-flex align-items-center flex-wrap">
<a href="https://www.odoo.com/documentation/16.0/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-access-key"
class="mx-2" target="_blank">Get an Access key</a>
and paste it here:
<input type="text"
class="o_input form-control w-auto mx-2"
id="accessKeyInput"
placeholder="Paste your access key here"
t-model="state.key"
t-on-input="() => this.state.hasKeyError = false"
t-att-class="{ 'is-invalid': state.hasKeyError }"/>
and paste
<a href="https://www.odoo.com/documentation/16.0/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-application-id"
class="mx-2" target="_blank">Application ID</a>
here:
<div class="input-group d-flex justify-content-end align-items-center w-auto mx-2">
<input type="text"
class="o_input form-control w-auto"
placeholder="Paste your application ID here"
t-model="state.appId"
t-on-input="() => this.state.hasAppIdError = false"
t-att-class="{ 'is-invalid': state.hasAppIdError }"/>
<button type="button" class="btn btn-primary btn-block w-auto p-auto save_unsplash" t-on-click="() => this.submitCredentials()">Apply</button>
</div>
</div>
</t>
<t t-name="web_unsplash.ImagesListTemplate" t-inherit="web_editor.ImagesListTemplate" t-inherit-mode="extension">
<xpath expr="//t[@id='o_we_media_library_images']" position="after">
<t t-if="['all', 'unsplash'].includes(state.searchService)">
<t t-foreach="state.unsplashRecords" t-as="record" t-key="record.id">
<AutoResizeImage src="record.url"
author="record.user.name"
authorLink="record.user.links.html"
name="record.user.name"
title="record.user.name"
altDescription="record.alt_description"
selected="this.selectedRecordIds.includes(record.id)"
onImageClick="() => this.onClickRecord(record)"
minRowHeight="MIN_ROW_HEIGHT"
onLoaded="(imgEl) => this.onImageLoaded(imgEl, record)"/>
</t>
</t>
</xpath>
</t>
<t t-inherit="web_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//div[@name='load_more_attachments']" position="after">
<div t-if="state.unsplashError" class="d-flex mt-2 unsplash_error">
<UnsplashError
title="errorTitle"
subtitle="errorSubtitle"
showCredentials="['key_not_found', 401].includes(state.unsplashError)"
submitCredentials="(key, appId) => this.submitCredentials(key, appId)"
hasCredentialsError="state.unsplashError === 401"/>
</div>
</xpath>
</t>
<t t-inherit="web_editor.FileSelectorControlPanel" t-inherit-mode="extension">
<xpath expr="//option[@value='media-library']" position="after">
<option t-if="props.useUnsplash" t-att-selected="props.searchService === 'unsplash'" value="unsplash">Photos (via Unsplash)</option>
</xpath>
</t>
<t t-inherit="web_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//FileSelectorControlPanel" position="attributes">
<attribute name="useUnsplash">state.useUnsplash</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,34 @@
odoo.define('web_unsplash.beacon', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.UnsplashBeacon = publicWidget.Widget.extend({
// /!\ To adapt the day the beacon makes sense for backend customizations
selector: '#wrapwrap',
/**
* @override
*/
start: function () {
var unsplashImages = _.map(this.$('img[src*="/unsplash/"]'), function (img) {
// get image id from URL (`http://www.domain.com:1234/unsplash/xYdf5feoI/lion.jpg` -> `xYdf5feoI`)
return img.src.split('/unsplash/')[1].split('/')[0];
});
if (unsplashImages.length) {
this._rpc({
route: '/web_unsplash/get_app_id',
}).then(function (appID) {
if (!appID) {
return;
}
$.get('https://views.unsplash.com/v', {
'photo_id': unsplashImages.join(','),
'app_id': appID,
});
});
}
return this._super.apply(this, arguments);
},
});
});

View file

@ -0,0 +1,59 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
export const unsplashService = {
dependencies: ['rpc'],
async start(env, { rpc }) {
const _cache = {};
return {
async getImages(query, offset = 0, pageSize = 30, orientation) {
const from = offset;
const to = offset + pageSize;
// Use orientation in the cache key to not show images in cache
// when using the same query word but changing the orientation
let cachedData = orientation ? _cache[query + orientation] : _cache[query];
if (cachedData && (cachedData.images.length >= to || (cachedData.totalImages !== 0 && cachedData.totalImages < to))) {
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
}
cachedData = await this._fetchImages(query, orientation);
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
},
/**
* Fetches images from unsplash and stores it in cache
*/
async _fetchImages(query, orientation) {
const key = orientation ? query + orientation : query;
if (!_cache[key]) {
_cache[key] = {
images: [],
maxPages: 0,
totalImages: 0,
pageCached: 0
};
}
const cachedData = _cache[key];
const payload = {
query: query,
page: cachedData.pageCached + 1,
per_page: 30, // max size from unsplash API
};
if (orientation) {
payload.orientation = orientation;
}
const result = await rpc('/web_unsplash/fetch_images', payload);
if (result.error) {
return Promise.reject(result.error);
}
cachedData.pageCached++;
cachedData.images.push(...result.results);
cachedData.maxPages = result.total_pages;
cachedData.totalImages = result.total;
return cachedData;
},
};
},
};
registry.category('services').add('unsplash', unsplashService);