mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-20 07:32:09 +02:00
Initial commit: Web packages
This commit is contained in:
commit
cd458d4b85
791 changed files with 410049 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue