7.x Structural Changes (#637)

* refactor(files): Initial commit on experimental file structure

* refactor(structure): New components system

* refactor(structure): Tidy settings' components

* Refactor(structure): Component exports and imports

* refactor(settings): Use new component imports

* feat: unified background.js script

* fix(build): Partially, distributions still not ready

* feat: critical error on noscript, light theme support for it

* fix(background): Critical issue of code making every background #000

* refactor(welcome): Partition into different files + shared components
- This took too long and destroyed my sanity

---------

Co-authored-by: alexsparkes <turbomarshmello@gmail.com>
Co-authored-by: alexsparkes <alexsparkes@gmail.com>
This commit is contained in:
David Ralph
2024-02-18 23:05:15 +00:00
committed by GitHub
parent 8fc6b1bf1b
commit 10f12b20c5
233 changed files with 979 additions and 426 deletions

View File

@@ -0,0 +1,90 @@
import { PureComponent, Fragment, Suspense, lazy } from 'react';
import Clock from './time/Clock';
import Greeting from './greeting/Greeting';
import Quote from './quote/Quote';
import Search from './search/Search';
import QuickLinks from './quicklinks/QuickLinks';
import Date from './time/Date';
import Message from './message/Message';
import { WidgetsLayout } from 'components/Layout';
import EventBus from 'modules/helpers/eventbus';
// weather is lazy loaded due to the size of the weather icons module
// since we're using react-icons this might not be accurate,
// however, when we used the original module https://bundlephobia.com/package/weather-icons-react@1.2.0
// as seen here it is ridiculously large
const Weather = lazy(() => import('./weather/Weather'));
export default class Widgets extends PureComponent {
online = localStorage.getItem('offlineMode') === 'false';
constructor() {
super();
this.state = {
order: JSON.parse(localStorage.getItem('order')),
welcome: localStorage.getItem('showWelcome'),
};
// widgets we can re-order
this.widgets = {
time: this.enabled('time') && <Clock />,
greeting: this.enabled('greeting') && <Greeting />,
quote: this.enabled('quote') && <Quote />,
date: this.enabled('date') && <Date />,
quicklinks: this.enabled('quicklinksenabled') && this.online ? <QuickLinks /> : null,
message: this.enabled('message') && <Message />,
};
}
enabled(key) {
return localStorage.getItem(key) === 'true';
}
componentDidMount() {
EventBus.on('refresh', (data) => {
switch (data) {
case 'widgets':
return this.setState({
order: JSON.parse(localStorage.getItem('order')),
});
case 'widgetsWelcome':
this.setState({
welcome: localStorage.getItem('showWelcome'),
});
localStorage.setItem('showWelcome', true);
window.onbeforeunload = () => {
localStorage.clear();
};
break;
case 'widgetsWelcomeDone':
this.setState({
welcome: localStorage.getItem('showWelcome'),
});
return (window.onbeforeunload = null);
default:
break;
}
});
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
// don't show when welcome is there
return this.state.welcome !== 'false' ? (
<WidgetsLayout />
) : (
<WidgetsLayout>
<Suspense fallback={<></>}>
{this.enabled('searchBar') && <Search />}
{this.state.order.map((element, key) => (
<Fragment key={key}>{this.widgets[element]}</Fragment>
))}
{this.enabled('weatherEnabled') && this.online ? <Weather /> : null}
</Suspense>
</WidgetsLayout>
);
}
}

View File

@@ -0,0 +1,582 @@
/* eslint-disable no-unused-expressions */
// todo: rewrite this mess
import variables from 'config/variables';
import { PureComponent } from 'react';
import PhotoInformation from './PhotoInformation';
import EventBus from 'modules/helpers/eventbus';
import {
videoCheck,
offlineBackground,
getGradient,
randomColourStyleBuilder,
} from 'modules/helpers/background/widget';
import './scss/index.scss';
import { decodeBlurHash } from 'fast-blurhash';
import { supportsAVIF } from 'modules/helpers/background/avif';
export default class Background extends PureComponent {
constructor() {
super();
this.state = {
blob: null,
style: '',
url: '',
currentAPI: '',
firstTime: false,
photoInfo: {
hidden: false,
offline: false,
photographerURL: '',
photoURL: '',
},
};
}
async setBackground() {
// clean up the previous image to prevent a memory leak
if (this.blob) {
URL.revokeObjectURL(this.blob);
}
const backgroundImage = document.getElementById('backgroundImage');
if (this.state.url !== '') {
let url = this.state.url;
if (
localStorage.getItem('ddgProxy') === 'true' &&
this.state.photoInfo.offline !== true &&
!this.state.url.startsWith('data:')
) {
url = variables.constants.DDG_IMAGE_PROXY + this.state.url;
}
const photoInformation = document.querySelector('.photoInformation');
// just set the background
if (localStorage.getItem('bgtransition') === 'false') {
photoInformation?.[(photoInformation.style.display = 'flex')];
return (backgroundImage.style.background = `url(${url})`);
}
backgroundImage.style.background = null;
if (this.state.photoInfo.blur_hash) {
backgroundImage.style.backgroundColor = this.state.photoInfo.colour;
backgroundImage.classList.add('fade-in');
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(32, 32);
imageData.data.set(decodeBlurHash(this.state.photoInfo.blur_hash, 32, 32));
ctx.putImageData(imageData, 0, 0);
backgroundImage.style.backgroundImage = `url(${canvas.toDataURL()})`;
}
this.blob = URL.createObjectURL(await (await fetch(url)).blob());
backgroundImage.classList.add('backgroundTransform');
backgroundImage.style.backgroundImage = `url(${this.blob})`;
} else {
// custom colour
backgroundImage.setAttribute('style', this.state.style);
}
}
async getAPIImageData(currentPun) {
let apiCategories;
try {
apiCategories = JSON.parse(localStorage.getItem('apiCategories'));
} catch (error) {
apiCategories = localStorage.getItem('apiCategories');
}
const backgroundAPI = localStorage.getItem('backgroundAPI');
const apiQuality = localStorage.getItem('apiQuality');
let backgroundExclude = JSON.parse(localStorage.getItem('backgroundExclude'));
if (!Array.isArray(backgroundExclude)) {
backgroundExclude = [];
}
if (currentPun) {
backgroundExclude.push(currentPun);
}
let requestURL, data;
switch (backgroundAPI) {
case 'unsplash':
case 'pexels':
const collection = localStorage.getItem('unsplashCollections');
if (collection) {
requestURL = `${variables.constants.API_URL}/images/unsplash?collections=${collection}&quality=${apiQuality}`;
} else {
requestURL = `${variables.constants.API_URL}/images/unsplash?categories=${apiCategories}&quality=${apiQuality}`;
}
break;
// Defaults to Mue
default:
requestURL = `${variables.constants.API_URL}/images/random?categories=${apiCategories}&quality=${apiQuality}&excludes=${backgroundExclude}`;
break;
}
const accept = 'application/json, ' + ((await supportsAVIF()) ? 'image/avif' : 'image/webp');
try {
data = await (await fetch(requestURL, { headers: { accept } })).json();
} catch (e) {
// if requesting to the API fails, we get an offline image
this.setState(offlineBackground('api'));
return null;
}
let photoURL, photographerURL;
if (backgroundAPI === 'unsplash') {
photoURL = data.photo_page;
photographerURL = data.photographer_page;
}
return {
url: data.file,
type: 'api',
currentAPI: backgroundAPI,
photoInfo: {
hidden: false,
category: data.category,
credit: data.photographer,
location: data.location.name,
camera: data.camera,
url: data.file,
photographerURL,
photoURL,
latitude: data.location.latitude || null,
longitude: data.location.longitude || null,
views: data.views || null,
downloads: data.downloads || null,
likes: data.likes || null,
description: data.description || null,
colour: data.colour,
blur_hash: data.blur_hash,
pun: data.pun || null,
},
};
}
// Main background getting function
async getBackground() {
let offline = localStorage.getItem('offlineMode') === 'true';
if (localStorage.getItem('showWelcome') === 'true') {
offline = true;
}
const setFavourited = ({ type, url, credit, location, camera, pun, offline }) => {
if (type === 'random_colour' || type === 'random_gradient') {
return this.setState({
type: 'colour',
style: `background:${url}`,
});
}
this.setState({
url,
photoInfo: {
credit,
location,
camera,
pun,
offline,
url,
},
});
};
const favourited = JSON.parse(localStorage.getItem('favourite'));
if (favourited) {
return setFavourited(favourited);
}
const type = localStorage.getItem('backgroundType');
switch (type) {
case 'api':
if (offline) {
return this.setState(offlineBackground('api'));
}
// API background
let data = JSON.parse(localStorage.getItem('nextImage')) || (await this.getAPIImageData());
localStorage.setItem('nextImage', null);
if (data) {
this.setState(data);
localStorage.setItem('currentBackground', JSON.stringify(data));
localStorage.setItem(
'nextImage',
JSON.stringify(await this.getAPIImageData(data.photoInfo.pun)),
); // pre-fetch data about the next image
}
break;
case 'colour':
const gradient = getGradient();
if (gradient) {
this.setState(gradient);
}
break;
case 'random_colour':
case 'random_gradient':
this.setState(randomColourStyleBuilder(type));
break;
case 'custom':
let customBackground = [];
const customSaved = localStorage.getItem('customBackground');
try {
customBackground = JSON.parse(customSaved);
} catch (e) {
if (customSaved !== '') {
// move to new format
customBackground = [customSaved];
}
localStorage.setItem('customBackground', JSON.stringify(customBackground));
}
// pick random
customBackground = customBackground[Math.floor(Math.random() * customBackground.length)];
// allow users to use offline images
if (offline && !customBackground.startsWith('data:')) {
return this.setState(offlineBackground('custom'));
}
if (
customBackground !== '' &&
customBackground !== 'undefined' &&
customBackground !== undefined
) {
const object = {
url: customBackground,
type: 'custom',
video: videoCheck(customBackground),
photoInfo: {
hidden: true,
},
};
this.setState(object);
localStorage.setItem('currentBackground', JSON.stringify(object));
}
break;
case 'photo_pack':
if (offline) {
return this.setState(offlineBackground('photo_pack'));
}
const photofavourited = JSON.parse(localStorage.getItem('favourite'));
if (photofavourited) {
return setFavourited(photofavourited);
}
const photoPack = [];
const installed = JSON.parse(localStorage.getItem('installed'));
installed.forEach((item) => {
if (item.type === 'photos') {
photoPack.push(...item.photos);
}
});
if (photoPack) {
const randomNumber = Math.floor(Math.random() * photoPack.length);
const randomPhoto = photoPack[randomNumber];
if (
(localStorage.getItem('backgroundchange') === 'refresh' &&
this.state.firstTime === true) ||
(localStorage.getItem('backgroundchange') === null && this.state.firstTime === true)
) {
if (this.state.firstTime !== true) {
localStorage.setItem('marketplaceNumber', randomNumber);
this.setState({
firstTime: false,
url: randomPhoto.url.default,
type: 'photo_pack',
photoInfo: {
hidden: false,
credit: randomPhoto.photographer,
location: randomPhoto.location,
},
});
}
} else {
if (
Number(
Number(localStorage.getItem('backgroundStartTime')) +
Number(localStorage.getItem('backgroundchange')) >=
Number(Date.now()),
)
) {
const randomPhoto = photoPack[localStorage.getItem('marketplaceNumber')];
if (this.state.firstTime !== true) {
this.setState({
url: randomPhoto.url.default,
type: 'photo_pack',
photoInfo: {
hidden: false,
credit: randomPhoto.photographer,
location: randomPhoto.location,
},
});
} else {
this.setState({ firstTime: true });
}
this.setState({ firstTime: true });
} else {
localStorage.setItem('marketplaceNumber', randomNumber);
return this.setState({
url: randomPhoto.url.default,
type: 'photo_pack',
photoInfo: {
hidden: false,
credit: randomPhoto.photographer,
location: randomPhoto.location,
},
});
}
}
}
break;
default:
break;
}
}
componentDidMount() {
const element = document.getElementById('backgroundImage');
// this resets it so the fade in and getting background all works properly
const refresh = () => {
element.classList.remove('fade-in');
this.setState({
url: '',
style: '',
type: '',
video: false,
photoInfo: {
hidden: true,
},
});
this.getBackground();
};
EventBus.on('refresh', (data) => {
if (data === 'welcomeLanguage') {
localStorage.setItem('welcomeImage', JSON.stringify(this.state));
}
if (data === 'background') {
if (localStorage.getItem('background') === 'false') {
// user is using custom colour or image
if (this.state.photoInfo.hidden === false) {
document.querySelector('.photoInformation').style.display = 'none';
}
// video backgrounds
if (this.state.video === true) {
return (document.getElementById('backgroundVideo').style.display = 'none');
} else {
return (element.style.display = 'none');
}
}
// video backgrounds
if (this.state.video === true) {
document.getElementById('backgroundVideo').style.display = 'block';
} else {
if (this.state.photoInfo.hidden === false) {
try {
document.querySelector('.photoInformation').style.display = 'flex';
} catch (e) {
// Disregard exception
}
}
element.style.display = 'block';
}
const backgroundType = localStorage.getItem('backgroundType');
if (this.state.photoInfo.offline !== true) {
// basically check to make sure something has changed before we try getting another background
if (
backgroundType !== this.state.type ||
(this.state.type === 'api' &&
localStorage.getItem('backgroundAPI') !== this.state.currentAPI) ||
(this.state.type === 'custom' &&
localStorage.getItem('customBackground') !== this.state.url) ||
JSON.parse(localStorage.getItem('backgroundExclude')).includes(this.state.photoInfo.pun)
) {
return refresh();
}
} else if (backgroundType !== this.state.type) {
return refresh();
}
// background effects so we don't get another image again
const backgroundFilterSetting = localStorage.getItem('backgroundFilter');
const backgroundFilter = backgroundFilterSetting && backgroundFilterSetting !== 'none';
if (this.state.video === true) {
document.getElementById('backgroundVideo').style.webkitFilter =
`blur(${localStorage.getItem('blur')}px) brightness(${localStorage.getItem(
'brightness',
)}%) ${
backgroundFilter
? backgroundFilterSetting +
'(' +
localStorage.getItem('backgroundFilterAmount') +
'%)'
: ''
}`;
} else {
element.style.webkitFilter = `blur(${localStorage.getItem(
'blur',
)}px) brightness(${localStorage.getItem('brightness')}%) ${
backgroundFilter
? backgroundFilterSetting +
'(' +
localStorage.getItem('backgroundFilterAmount') +
'%)'
: ''
}`;
}
}
// uninstall photo pack reverts your background to what you had previously
if (
data === 'marketplacebackgrounduninstall' ||
data === 'backgroundwelcome' ||
data === 'backgroundrefresh'
) {
refresh();
}
});
if (localStorage.getItem('welcomeTab')) {
return this.setState(JSON.parse(localStorage.getItem('welcomeImage')));
}
if (
localStorage.getItem('backgroundchange') === 'refresh' ||
localStorage.getItem('backgroundchange') === null
) {
try {
document.getElementById('backgroundImage').classList.remove('fade-in');
document.getElementsByClassName('photoInformation')[0].classList.remove('fade-in');
} catch (e) {
// Disregard exception
}
this.getBackground();
localStorage.setItem('backgroundStartTime', Date.now());
}
const test = localStorage.getItem('backgroundchange');
this.interval = setInterval(() => {
const targetTime = Number(Number(localStorage.getItem('backgroundStartTime')) + Number(test));
const currentTime = Number(Date.now());
const type = localStorage.getItem('backgroundType');
if (test !== null && test !== 'refresh') {
if (currentTime >= targetTime) {
element.classList.remove('fade-in');
this.getBackground();
localStorage.setItem('backgroundStartTime', Date.now());
} else {
try {
const current = JSON.parse(localStorage.getItem('currentBackground'));
if (current.type !== type) {
this.getBackground();
}
const offline = localStorage.getItem('offlineMode');
if (current.url.startsWith('http') && offline === 'false') {
if (this.state.firstTime !== true) {
this.setState(current);
}
} else if (current.url.startsWith('http')) {
this.setState(offlineBackground());
}
if (this.state.firstTime !== true) {
this.setState(current);
}
this.setState({ firstTime: true });
} catch (e) {
this.setBackground();
}
}
}
});
}
// only set once we've got the info
componentDidUpdate() {
if (this.state.video === true) {
return;
}
this.setBackground();
}
componentWillUnmount() {
EventBus.off('refresh');
clearInterval(this.interval);
}
render() {
if (this.state.video === true) {
const enabled = (setting) => {
return localStorage.getItem(setting) === 'true';
};
return (
<video
autoPlay
muted={enabled('backgroundVideoMute')}
loop={enabled('backgroundVideoLoop')}
style={{
WebkitFilter: `blur(${localStorage.getItem(
'blur',
)}px) brightness(${localStorage.getItem('brightness')}%)`,
}}
id="backgroundVideo"
>
<source src={this.state.url} />
</video>
);
}
const backgroundFilter = localStorage.getItem('backgroundFilter');
return (
<>
<div
style={{
WebkitFilter: `blur(${localStorage.getItem(
'blur',
)}px) brightness(${localStorage.getItem('brightness')}%) ${
backgroundFilter && backgroundFilter !== 'none'
? backgroundFilter + '(' + localStorage.getItem('backgroundFilterAmount') + '%)'
: ''
}`,
}}
id="backgroundImage"
/>
{this.state.photoInfo.credit !== '' && (
<PhotoInformation
info={this.state.photoInfo}
api={this.state.currentAPI}
url={this.state.url}
/>
)}
</>
);
}
}

View File

@@ -0,0 +1,49 @@
import variables from 'config/variables';
import { memo } from 'react';
import EventBus from 'modules/helpers/eventbus';
import { Tooltip } from 'components/Elements';
import { MdClose, MdDone } from 'react-icons/md';
function ExcludeModal({ modalClose, info }) {
const excludeImage = async () => {
let backgroundExclude = JSON.parse(localStorage.getItem('backgroundExclude'));
backgroundExclude.push(info.pun);
backgroundExclude = JSON.stringify(backgroundExclude);
localStorage.setItem('backgroundExclude', backgroundExclude);
EventBus.emit('refresh', 'background');
modalClose();
};
return (
<div className="smallModal">
<div className="shareHeader">
<span className="title">
{variables.getMessage('modals.main.settings.sections.advanced.reset_modal.title')}
</span>
<Tooltip
title={variables.getMessage('modals.main.settings.sections.advanced.reset_modal.cancel')}
>
<div className="close" onClick={modalClose}>
<MdClose />
</div>
</Tooltip>
</div>
<span className="subtitle">
{variables.getMessage('widgets.background.exclude_confirm', { category: info.category })}
</span>
<div className="resetFooter">
<button className="textButton" onClick={modalClose}>
<MdClose />
{variables.getMessage('modals.main.settings.sections.advanced.reset_modal.cancel')}
</button>
<button onClick={() => excludeImage()}>
<MdDone />
{variables.getMessage('widgets.background.confirm')}
</button>
</div>
</div>
);
}
export default memo(ExcludeModal);

View File

@@ -0,0 +1,107 @@
import variables from 'config/variables';
import { PureComponent } from 'react';
import { MdStar, MdStarBorder } from 'react-icons/md';
class Favourite extends PureComponent {
buttons = {
favourited: <MdStar onClick={() => this.favourite()} className="topicons" />,
unfavourited: <MdStarBorder onClick={() => this.favourite()} className="topicons" />,
};
constructor() {
super();
this.state = {
favourited: localStorage.getItem('favourite')
? this.buttons.favourited
: this.buttons.unfavourited,
};
}
async favourite() {
if (localStorage.getItem('favourite')) {
localStorage.removeItem('favourite');
this.setState({
favourited: this.buttons.unfavourited,
});
variables.stats.postEvent('feature', 'Background favourite');
} else {
const type = localStorage.getItem('backgroundType');
switch (type) {
case 'colour':
return;
case 'random_colour':
case 'random_gradient':
localStorage.setItem(
'favourite',
JSON.stringify({
type: localStorage.getItem('backgroundType'),
url: document.getElementById('backgroundImage').style.background,
}),
);
break;
default:
let url = document
.getElementById('backgroundImage')
.style.backgroundImage.replace('url("', '')
.replace('")', '')
.replace(variables.constants.DDG_IMAGE_PROXY, '');
if (!url) {
return;
}
if (url.startsWith('blob:')) {
const reader = new FileReader();
url = await new Promise(async (resolve) => {
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(await (await fetch(url)).blob());
});
}
if (type === 'custom') {
localStorage.setItem(
'favourite',
JSON.stringify({
type,
url,
}),
);
} else {
// photo information now hides information if it isn't sent, unless if photoinformation hover is hidden
const location = document.getElementById('infoLocation');
const camera = document.getElementById('infoCamera');
localStorage.setItem(
'favourite',
JSON.stringify({
type,
url,
credit: this.props.credit || '',
location: location?.innerText,
camera: camera?.innerText,
resolution: document.getElementById('infoResolution').textContent || '',
offline: this.props.offline,
pun: this.props.pun,
}),
);
}
}
this.setState({
favourited: this.buttons.favourited,
});
variables.stats.postEvent('feature', 'Background unfavourite');
}
}
render() {
if (localStorage.getItem('backgroundType') === 'colour') {
return null;
}
return this.state.favourited;
}
}
export default Favourite;

View File

@@ -0,0 +1,89 @@
import variables from 'config/variables';
import { PureComponent } from 'react';
import { MdCropFree } from 'react-icons/md';
import { Tooltip } from 'components/Elements';
class Maximise extends PureComponent {
constructor() {
super();
this.state = {
hidden: false,
};
}
setAttribute(blur, brightness, filter) {
// don't attempt to modify the background if it isn't an image
const backgroundType = localStorage.getItem('backgroundType');
if (
backgroundType === 'colour' ||
backgroundType === 'random_colour' ||
backgroundType === 'random_gradient'
) {
return;
}
const element = document.getElementById('backgroundImage');
let backgroundFilter;
if (filter === true) {
const filterData = localStorage.getItem('backgroundFilter');
if (filterData !== 'none') {
backgroundFilter = filterData;
}
}
element.setAttribute(
'style',
`background-image: url(${element.style.backgroundImage
.replace('url("', '')
.replace('")', '')}); -webkit-filter: blur(${blur}px) brightness(${brightness}%) ${
backgroundFilter
? backgroundFilter + '(' + localStorage.getItem('backgroundFilterAmount') + '%)'
: ''
};`,
);
}
maximise = () => {
// hide widgets
const widgets = document.getElementById('widgets');
this.state.hidden === false
? (widgets.style.display = 'none')
: (widgets.style.display = 'flex');
if (this.state.hidden === false) {
this.setState({
hidden: true,
});
this.setAttribute(0, 100);
variables.stats.postEvent('feature', 'Background maximise');
} else {
this.setState({
hidden: false,
});
this.setAttribute(localStorage.getItem('blur'), localStorage.getItem('brightness'), true);
variables.stats.postEvent('feature', 'Background unmaximise');
}
};
render() {
return (
<Tooltip
title={variables.getMessage('modals.main.settings.sections.background.buttons.view')}
>
<button
style={{ fontSize: this.props.fontSize }}
onClick={this.maximise}
aria-label={variables.getMessage('modals.main.settings.sections.background.buttons.view')}
>
<MdCropFree className="topicons" />
</button>
</Tooltip>
);
}
}
export default Maximise;

View File

@@ -0,0 +1,393 @@
import variables from 'config/variables';
import { useState, memo } from 'react';
import Favourite from './Favourite';
import {
MdInfo,
MdLocationOn,
MdPhotoCamera,
MdCrop as Resolution,
MdGetApp as Download,
MdVisibility as Views,
MdIosShare as Share,
MdSource as Source,
MdFavorite as MdFavourite,
MdCategory as Category,
MdVisibilityOff as VisibilityOff,
} from 'react-icons/md';
import { Tooltip } from 'components/Elements';
import Modal from 'react-modal';
import { ShareModal } from 'components/Elements';
import ExcludeModal from './ExcludeModal';
/**
* It takes a URL, fetches the resource, and returns a URL to the resource.
* @param {string} url The URL to fetch.
* @returns A promise that resolves to a blob URL.
*/
const toDataURL = async (url) => {
const res = await fetch(url);
return URL.createObjectURL(await res.blob());
};
/**
* It takes a string, makes it lowercase, removes commas, and replaces spaces with dashes.
* @param {string} text The string to format.
* @returns A function that takes a string and returns a string.
*/
const formatText = (text) => {
return text.toLowerCase().replaceAll(',', '').replaceAll(' ', '-');
};
/**
* It downloads an image from a URL and saves it to the user's computer.
* @param {object} info The photo information.
*/
const downloadImage = async (info) => {
const link = document.createElement('a');
link.href = await toDataURL(info.url);
link.download = `mue-${formatText(info.credit)}-${formatText(info.location)}.jpg`; // image is more likely to be webp or avif btw
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
variables.stats.postEvent('feature', 'Background download');
};
function PhotoInformation({ info, url, api }) {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [usePhotoMap, setPhotoMap] = useState(false);
const [useMapIcon, setMapIcon] = useState(true);
const [showExtraInfo, setshowExtraInfo] = useState(false);
//const [showOld, setShowOld] = useState(true);
const [other, setOther] = useState(false);
const [shareModal, openShareModal] = useState(false);
const [excludeModal, openExcludeModal] = useState(false);
if (info.hidden === true || !info.credit) {
return null;
}
// remove unsplash text
const unsplash = variables.getMessage('widgets.background.unsplash');
let credit = info.credit;
let photo = variables.getMessage('widgets.background.credit');
// unsplash credit
if (info.photographerURL && info.photographerURL !== '' && !info.offline && api) {
photo = (
<a href={info.photoURL + '?utm_source=mue'} target="_blank" rel="noopener noreferrer">
{photo}
</a>
);
credit = (
<>
<a href={info.photographerURL} target="_blank" rel="noopener noreferrer">
{info.credit}
</a>{' '}
<a href="https://unsplash.com?utm_source=mue" target="_blank" rel="noopener noreferrer">
{unsplash}
</a>
</>
);
}
const ddgProxy = localStorage.getItem('ddgProxy') === 'true';
// get resolution
const img = new Image();
img.onload = (event) => {
setWidth(event.target.width);
setHeight(event.target.height);
};
img.src =
ddgProxy && !info.offline && !url.startsWith('data:')
? variables.constants.DDG_IMAGE_PROXY + url
: url;
// info is still there because we want the favourite button to work
if (localStorage.getItem('photoInformation') === 'false') {
return (
<div style={{ display: 'none' }}>
<span id="credit">{credit}</span>
<span id="infoLocation">{info.location}</span>
<span id="infoCamera">{info.camera}</span>
<span id="infoResolution">
{width}x{height}
</span>
</div>
);
}
let showingPhotoMap = false;
const photoMap = () => {
if (
localStorage.getItem('photoMap') !== 'true' ||
!info.latitude ||
!info.longitude ||
usePhotoMap === false
) {
return null;
}
const tile =
variables.constants.API_URL + `/map?latitude=${info.latitude}&longitude=${info.longitude}`;
showingPhotoMap = true;
return (
<a
href={`${variables.constants.OPENSTREETMAP_URL}/?mlat=${info.latitude}&mlon=${info.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
<img className="locationMap" src={tile} alt="location" draggable={false} />
</a>
);
};
const InformationItems = () => {
return (
<div className="extra-content">
{info.location && info.location !== 'N/A' ? (
<div className="row" title={variables.getMessage('widgets.background.location')}>
<MdLocationOn />
<span id="infoLocation">{info.location}</span>
</div>
) : null}
{info.camera && info.camera !== 'N/A' ? (
<div className="row" title={variables.getMessage('widgets.background.camera')}>
<MdPhotoCamera />
<span id="infoCamera">{info.camera}</span>
</div>
) : null}
<div className="row" title={variables.getMessage('widgets.background.resolution')}>
<Resolution />
<span id="infoResolution">
{width}x{height}
</span>
</div>
{info.category && (
<div className="row" title={variables.getMessage('widgets.background.category')}>
<Category />
<span id="infoCategory">{info.category[0].toUpperCase() + info.category.slice(1)}</span>
</div>
)}
{api && (
<div className="row" title={variables.getMessage('widgets.background.source')}>
<Source />
<span id="infoSource">
{info.photoURL ? (
<a
href={info.photoURL + '?utm_source=mue'}
target="_blank"
rel="noopener noreferrer"
className="link"
>
{api.charAt(0).toUpperCase() + api.slice(1)}
</a>
) : (
<a href={info.url} target="_blank" rel="noopener noreferrer" className="link">
{api.charAt(0).toUpperCase() + api.slice(1)}
</a>
)}
</span>
</div>
)}
</div>
);
};
const ActionButtons = () => {
return (
<div className="buttons">
{!info.offline && (
<Tooltip title={variables.getMessage('widgets.quote.share')} key="share" placement="top">
<Share onClick={() => openShareModal(true)} />
</Tooltip>
)}
<Tooltip
title={variables.getMessage('widgets.quote.favourite')}
key="favourite"
placement="top"
>
<Favourite
pun={info.pun}
offline={info.offline}
credit={info.credit}
photoURL={info.url}
/>
</Tooltip>
{!info.offline && (
<Tooltip
title={variables.getMessage('widgets.background.download')}
key="download"
placement="top"
>
<Download onClick={() => downloadImage(info)} />
</Tooltip>
)}
{info.pun && (
<Tooltip
title={variables.getMessage('widgets.background.exclude')}
key="exclude"
placement="top"
>
<VisibilityOff onClick={() => openExcludeModal(true)} />
</Tooltip>
)}
</div>
);
};
const UnsplashStats = () => {
return (
<div className="unsplashStats">
<div title={variables.getMessage('widgets.background.views')}>
<Views />
<span>{info.views.toLocaleString()}</span>
</div>
<div title={variables.getMessage('widgets.background.downloads')}>
<Download />
<span>{info.downloads.toLocaleString()}</span>
</div>
{!!info.likes ? (
<div title={variables.getMessage('widgets.background.likes')}>
<MdFavourite />
<span>{info.likes.toLocaleString()}</span>
</div>
) : null}
</div>
);
};
let photoMapClassList = 'map-concept';
if (photoMap() !== null) {
photoMapClassList += ' photoMap';
}
// only request map image if the user looks at the photo information
// this is to reduce requests to the api
try {
document.getElementsByClassName('photoInformation')[0].onmouseover = () => {
try {
setPhotoMap(true);
setMapIcon(false);
} catch (e) {}
};
} catch (e) {}
const widgetStyle = localStorage.getItem('widgetStyle');
return (
<div
className="photoInformationHolder"
onMouseEnter={() => setOther(true)}
onMouseLeave={() => setOther(false)}
>
<Modal
closeTimeoutMS={300}
isOpen={shareModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => openShareModal(false)}
>
<ShareModal data={info.photoURL || info.url} modalClose={() => openShareModal(false)} />
</Modal>
<Modal
closeTimeoutMS={300}
isOpen={excludeModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => openExcludeModal(false)}
>
<ExcludeModal info={info} modalClose={() => openExcludeModal(false)} />
</Modal>
{widgetStyle === 'legacy' && (
<div className="photoInformation-legacy">
<MdInfo />
<span className="title">
{photo} <span id="credit">{credit}</span>
</span>
</div>
)}
{widgetStyle !== 'legacy' || other ? (
<div
className="photoInformation orHover"
style={{ padding: widgetStyle === 'legacy' && '20px' }}
onMouseEnter={() => setshowExtraInfo(true)}
onMouseLeave={() => setshowExtraInfo(false)}
>
<div className={photoMapClassList}>
{useMapIcon || photoMap() === null ? <MdLocationOn /> : ''}
{photoMap()}
</div>
{showingPhotoMap && (
<div className="copyright">
<a
href="https://www.mapbox.com/about/maps/"
target="_blank"
rel="noopener noreferrer"
>
{' '}
© Mapbox{' '}
</a>{' '}
{' '}
<a
href="https://www.openstreetmap.org/about/"
target="_blank"
rel="noopener noreferrer"
>
{' '}
© OpenStreetMap{' '}
</a>{' '}
{' '}
<a
href="https://www.mapbox.com/map-feedback/"
target="_blank"
rel="noopener noreferrer"
>
{' '}
Improve this map{' '}
</a>
</div>
)}
<div className="photoInformation-content">
<div className="photoInformation-text">
<span
className="title"
title={
(showExtraInfo || other) && info.description ? info.description : info.location
}
>
{(showExtraInfo || other) && info.description
? info.description.length > 40
? info.description.substring(0, 40) + '...'
: info.description
: info.location?.split(',').slice(-2).join(', ').trim()}
</span>
<span className="subtitle" id="credit">
{photo} {credit}
</span>
</div>
{info.views && info.downloads !== null ? <UnsplashStats /> : null}
</div>
{showExtraInfo || other ? (
<>
<span className="subtitle">
{variables.getMessage('widgets.background.information')}
</span>
<InformationItems />
<ActionButtons />
</>
) : null}
</div>
) : null}
</div>
);
}
export default memo(PhotoInformation);

View File

@@ -0,0 +1,475 @@
@import 'scss/variables';
.photoInformationHolder {
position: absolute;
bottom: 1rem;
left: 1rem;
flex-flow: column-reverse;
display: flex;
&:hover {
.photoInformation {
height: auto;
align-items: flex-start;
flex-flow: column;
.buttons {
padding: 30px 0 0;
display: flex;
}
.extra-content {
margin-top: 10px;
display: grid;
grid-template-columns: auto auto;
gap: 10px;
transition: 0.8s;
}
.stats {
display: flex;
padding-top: 5px;
gap: 20px;
@include themed {
color: t($subColor);
}
div {
display: flex;
align-items: center;
align-content: center;
flex-direction: row;
gap: 5px;
svg {
font-size: 1em;
}
}
}
.copyright {
display: flex;
margin-top: 10px;
gap: 10px;
a {
text-decoration: none;
@include themed {
color: t($subColor);
}
}
}
.photoInformation-content {
padding: 20px 20px 20px 0;
}
.map-concept {
width: 100%;
}
}
}
}
.mapOn {
width: 100%;
height: 150px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
img {
flex-shrink: 0;
min-width: 100%;
min-height: 100%;
}
svg {
display: none;
}
}
.photoInformation-legacy {
font-size: 1.36em;
text-shadow: 0 0 10px rgb(0 0 0 / 30%);
color: #fff;
z-index: 99;
user-select: none;
cursor: initial;
font-weight: bolder;
svg {
font-size: 1.36em;
}
&:hover .infoCard {
display: block !important;
}
display: flex;
align-items: center;
gap: 15px;
svg:hover + .infoCard {
display: block;
}
a {
color: white;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
+ .photoInformation {
animation: none !important;
}
}
.infoCard {
display: none;
background: var(--background);
box-shadow: 0 0 10px rgb(0 0 0 / 30%);
color: var(--modal-text);
position: fixed;
bottom: 2.9rem;
left: 0.7em;
padding: 1rem;
border-radius: 24px 24px 24px 0;
width: 300px !important;
text-align: left;
text-shadow: none;
svg {
margin-right: 0.5rem;
vertical-align: middle;
display: inline-flex;
padding: 2px;
}
.locationMap {
height: 100px;
object-fit: cover;
width: 100%;
background-position: center center;
background-repeat: no-repeat;
}
.mapboxLogo {
height: 20px;
width: auto;
}
span,
svg {
font-size: 2em;
}
span {
user-select: text;
}
h1,
.MuiSvgIcon-root {
user-select: none;
cursor: initial;
}
h1,
.infoIcon {
font-size: 3em;
}
.infoIcon {
padding: 4px;
}
hr {
height: 3px;
background: var(--photo-info);
outline: none;
border: none;
}
&:hover,
span {
display: block !important;
}
.download {
text-decoration: underline;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.mapCopyright {
font-size: 0.9em;
}
}
.photoInformation {
@extend %basic;
animation: fadeIn 2s;
min-width: 300px;
font-size: 0.8em;
font-weight: 300;
z-index: 99;
display: flex;
flex-flow: row;
align-items: center;
transition: 0.8s cubic-bezier(0.075, 0.82, 0.165, 1);
// width: 300px;
padding: 10px;
.link {
text-decoration: none;
cursor: pointer;
@include themed {
color: t($link);
}
&:hover {
opacity: 0.8;
}
}
&:hover {
padding: 20px;
height: auto;
align-items: flex-start;
flex-flow: column;
.buttons {
padding: 30px 0 0;
display: flex;
}
.extra-content {
display: flex;
flex-flow: column;
gap: 10px;
transition: 0.8s;
}
.stats {
display: flex;
gap: 15px;
@include themed {
color: t($subColor);
}
div {
display: flex;
align-items: center;
align-content: center;
flex-direction: row;
gap: 10px;
svg {
font-size: 1em;
}
}
}
.copyright {
display: flex;
margin-top: 10px;
gap: 10px;
a {
text-decoration: none;
@include themed {
color: t($subColor);
}
}
}
.photoInformation-content {
padding-left: 0;
}
.map-concept {
width: 100%;
}
}
.map-concept {
@extend %basic;
height: 50px;
width: 50px;
flex-grow: 0;
flex-shrink: 0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
border: none !important;
svg {
font-size: 24px;
}
img {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-size: cover;
object-fit: cover;
border-radius: 12px;
}
}
.location {
font-size: 1.9em;
}
.credit {
font-size: 1.4em;
@include themed {
color: t($subColor);
}
a {
@include themed {
color: t($subColor);
}
&:nth-child(2) {
color: var(--modal-link);
}
}
}
}
.photoInformation-content {
line-height: 2em;
margin-right: 3px;
display: flex;
flex-flow: row;
align-items: center;
gap: 20px;
padding-left: 20px;
width: 100%;
justify-content: space-between;
a {
@include themed {
color: t($link);
}
text-decoration: none;
&:hover {
opacity: 0.9;
}
}
}
.extra-content {
display: none;
flex-flow: column;
width: 100%;
transition: 1s;
.row {
display: flex;
align-items: center;
gap: 20px;
svg {
@include themed {
color: t($subColor);
font-size: 18px;
}
}
}
}
.copyright {
display: none;
}
.stats {
display: none;
}
.buttons {
align-items: center;
gap: 20px;
padding: 20px 20px 0 0;
display: none;
svg {
@include basicIconButton(11px, 1.3rem, ui);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.photoMap {
img {
height: 50px !important;
width: 50px !important;
}
a {
height: 50px;
}
}
.photoInformation:hover,
.photoInformation-legacy + .photoInformation {
.photoMap {
height: auto !important;
img {
height: auto !important;
width: auto !important;
}
a {
height: auto !important;
}
}
}
.unsplashStats {
display: flex;
flex-flow: column;
justify-content: space-between;
div {
display: flex;
flex-flow: row;
gap: 10px;
align-items: center;
}
}
.photoInformation-text {
display: flex;
flex-flow: column;
}

View File

@@ -0,0 +1,53 @@
@import 'photoinformation';
@import 'scss/mixins';
#backgroundImage {
height: 100vh;
background-size: cover !important;
background-repeat: no-repeat !important;
background-position: center !important;
background-attachment: fixed !important;
zoom: 100% !important;
}
.backgroundTransform {
@include animation('fadein2 2s');
transition: background 2s;
}
@include keyframes(fadein2) {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.backgroundPreload {
opacity: 0;
}
#backgroundVideo {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
}
.fade-in {
@include animation('fadein 1s');
}
@include keyframes(fadein) {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,164 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import { nth, convertTimezone } from 'modules/helpers/date';
import EventBus from 'modules/helpers/eventbus';
import './greeting.scss';
export default class Greeting extends PureComponent {
constructor() {
super();
this.state = {
greeting: '',
};
this.timer = undefined;
this.greeting = createRef();
}
/**
* Change the greeting message for the events of Christmas, New Year and Halloween.
* If the events setting is disabled, then the greeting message will not be changed.
* @param {Date} time The current time.
* @param {String} message The current greeting message.
* @returns The message variable is being returned.
*/
doEvents(time, message) {
if (localStorage.getItem('events') === 'false') {
return message;
}
// Get current month & day
const month = time.getMonth();
const date = time.getDate();
// If it's December 25th, set the greeting string to "Merry Christmas"
if (month === 11 && date === 25) {
message = variables.getMessage('widgets.greeting.christmas');
// If the date is January 1st, set the greeting string to "Happy new year"
} else if (month === 0 && date === 1) {
message = variables.getMessage('widgets.greeting.newyear');
// If it's October 31st, set the greeting string to "Happy Halloween"
} else if (month === 9 && date === 31) {
message = variables.getMessage('widgets.greeting.halloween');
}
return message;
}
/**
* It takes a date object and returns the age of the person in years.
* @param {Date} date The date of birth.
* @returns The age of the person.
*/
calculateAge(date) {
const diff = Date.now() - date.getTime();
const birthday = new Date(diff);
return Math.abs(birthday.getUTCFullYear() - 1970);
}
getGreeting(time = 60000 - (Date.now() % 60000)) {
this.timer = setTimeout(() => {
let now = new Date();
const timezone = localStorage.getItem('timezone');
if (timezone && timezone !== 'auto') {
now = convertTimezone(now, timezone);
}
const hour = now.getHours();
// Set the default greeting string to "Good evening"
let message = variables.getMessage('widgets.greeting.evening');
// If it's before 12am, set the greeting string to "Good morning"
if (hour < 12) {
message = variables.getMessage('widgets.greeting.morning');
// If it's before 6pm, set the greeting string to "Good afternoon"
} else if (hour < 18) {
message = variables.getMessage('widgets.greeting.afternoon');
}
// Events and custom
const custom = localStorage.getItem('defaultGreetingMessage');
if (custom === 'false') {
message = '';
} else {
message = this.doEvents(now, message);
}
// Name
let name = '';
const data = localStorage.getItem('greetingName');
if (typeof data === 'string') {
if (data.replace(/\s/g, '').length > 0) {
name = `, ${data.trim()}`;
}
}
const birthday = localStorage.getItem('birthdayenabled');
if (custom === 'false' && birthday !== 'true') {
name = name.replace(',', '');
}
// Birthday
if (birthday === 'true') {
const birth = new Date(localStorage.getItem('birthday'));
if (birth.getDate() === now.getDate() && birth.getMonth() === now.getMonth()) {
if (localStorage.getItem('birthdayage') === 'true' && this.calculateAge(birth) !== 0) {
const text = variables.getMessage('widgets.greeting.birthday').split(' ');
message = `${text[0]} ${nth(this.calculateAge(birth))} ${text[1]}`;
} else {
message = variables.getMessage('widgets.greeting.birthday');
}
}
}
// Set the state to the greeting string
this.setState({
greeting: `${message}${name}`,
});
this.getGreeting();
}, time);
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'greeting' || data === 'timezone') {
if (localStorage.getItem('greeting') === 'false') {
return (this.greeting.current.style.display = 'none');
}
this.timer = null;
this.getGreeting(0);
this.greeting.current.style.display = 'block';
this.greeting.current.style.fontSize = `${
1.6 * Number((localStorage.getItem('zoomGreeting') || 100) / 100)
}em`;
}
});
// this comment can apply to all widget zoom features apart from the general one in the Accessibility section
// in a nutshell: 1.6 is the current font size, and we do "localstorage || 100" so we don't have to try that 4.0 -> 5.0 thing again
this.greeting.current.style.fontSize = `${
1.6 * Number((localStorage.getItem('zoomGreeting') || 100) / 100)
}em`;
this.getGreeting(0);
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
return (
<span className="greeting" ref={this.greeting}>
{this.state.greeting}
</span>
);
}
}

View File

@@ -0,0 +1,9 @@
.greeting {
margin: 0;
font-size: 1.6em;
cursor: initial;
user-select: none;
font-weight: 600;
--shadow-shift: 0.2rem;
}

View File

@@ -0,0 +1,45 @@
import { useState, useEffect, useRef } from 'react';
import EventBus from 'modules/helpers/eventbus';
import './message.scss';
const Message = () => {
const [messageText, setMessageText] = useState('');
const [display, setDisplay] = useState('none');
const [fontSize, setFontSize] = useState('1em');
const message = useRef();
useEffect(() => {
const handleRefresh = (data) => {
if (data === 'message') {
const messageSetting = localStorage.getItem('message');
const zoomMessage = localStorage.getItem('zoomMessage');
setDisplay(messageSetting === 'false' ? 'none' : 'block');
setFontSize(`${1 * Number((zoomMessage || 100) / 100)}em`);
}
};
const messages = JSON.parse(localStorage.getItem('messages')) || [];
if (messages.length > 0) {
setMessageText(messages[Math.floor(Math.random() * messages.length)]);
setDisplay('block');
}
EventBus.on('refresh', handleRefresh);
return () => {
EventBus.off('refresh');
};
}, []);
return (
<h2 className="message" ref={message} style={{ display, fontSize }}>
{messageText.split('\\n').map((item) => (
<span key={item}>
{item}
<br />
</span>
))}
</h2>
);
};
export default Message;

View File

@@ -0,0 +1,6 @@
.message {
cursor: default;
margin: 0;
user-select: none;
font-size: 1em;
}

View File

@@ -0,0 +1,154 @@
// TODO: make it work with pins or on click or smth
import variables from 'config/variables';
import { PureComponent, memo, useState } from 'react';
import { MdPlaylistRemove, MdOutlineApps } from 'react-icons/md';
import { Tooltip } from 'components/Elements';
import { shift, useFloating } from '@floating-ui/react-dom';
import EventBus from 'modules/helpers/eventbus';
class Apps extends PureComponent {
constructor() {
super();
this.state = {
apps: JSON.parse(localStorage.getItem('applinks')),
visibility: localStorage.getItem('appsPinned') === 'true' ? 'visible' : 'hidden',
marginLeft: localStorage.getItem('refresh') === 'false' ? '-200px' : '-130px',
showApps: localStorage.getItem('appsPinned') === 'true',
};
}
setZoom() {
this.setState({
zoomFontSize: Number(((localStorage.getItem('zoomNavbar') || 100) / 100) * 1.2) + 'rem',
});
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'navbar') {
this.forceUpdate();
try {
this.setZoom();
} catch (e) {}
}
});
this.setZoom();
}
componentWillUnmount() {
EventBus.off('refresh');
}
showApps() {
this.setState({
showApps: true,
});
}
hideApps() {
this.setState({
showApps: localStorage.getItem('AppsPinned') === 'true',
});
}
render() {
const appsInfo = this.state.apps;
return (
<div className="notes" onMouseLeave={() => this.hideApps()} onFocus={() => this.showApps()}>
<button
className="first"
onMouseEnter={() => this.showApps()}
onFocus={() => this.hideApps()}
onBlur={() => this.showApps()}
ref={this.props.appsRef}
style={{ fontSize: this.state.zoomFontSize }}
>
<MdOutlineApps className="topicons" />
</button>
{this.state.showApps && (
<span
className="notesContainer"
ref={this.props.floatRef}
style={{
position: this.props.position,
top: this.props.yPosition ?? '44px',
left: this.props.xPosition ?? '',
}}
>
<div className="flexTodo">
<div className="topBarNotes" style={{ display: 'flex' }}>
<MdOutlineApps />
<span>{variables.getMessage('widgets.navbar.apps.title')}</span>
</div>
</div>
{appsInfo.length > 0 ? (
<div className="appsShortcutContainer">
{appsInfo.map((info, i) => (
<Tooltip
title={info.name.split(' ')[0]}
subtitle={info.name.split(' ').slice(1).join(' ')}
key={i}
>
<a href={info.url} className="appsIcon">
<img
src={
info.icon === ''
? `https://icon.horse/icon/ ${info.url.replace('https://', '').replace('http://', '')}`
: info.icon
}
width="40px"
height="40px"
alt="Google"
/>
<span>{info.name}</span>
</a>
</Tooltip>
))}
</div>
) : (
<div className="todosEmpty">
<div className="emptyNewMessage">
<MdPlaylistRemove />
<span className="title">
{variables.language.getMessage(
variables.languagecode,
'widgets.navbar.apps.no_apps',
)}
</span>
</div>
</div>
)}
</span>
)}
</div>
);
}
}
function AppsWrapper() {
const [reference, setReference] = useState(null);
const { x, y, refs, strategy } = useFloating({
placement: 'bottom',
middleware: [shift()],
elements: {
reference,
},
});
return (
<Apps
appsRef={setReference}
floatRef={refs.setFloating}
position={strategy}
xPosition={x}
yPosition={y}
/>
);
}
export default memo(AppsWrapper);

View File

@@ -0,0 +1,162 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import { MdRefresh, MdSettings } from 'react-icons/md';
import Notes from './Notes';
import Todo from './Todo';
import Apps from './Apps';
import Maximise from '../background/Maximise';
import { Tooltip } from 'components/Elements';
import EventBus from 'modules/helpers/eventbus';
import './scss/index.scss';
class Navbar extends PureComponent {
constructor() {
super();
this.navbarContainer = createRef();
this.state = {
classList: localStorage.getItem('widgetStyle') === 'legacy' ? 'navbar old' : 'navbar new',
refreshText: '',
refreshEnabled: localStorage.getItem('refresh'),
refreshOption: localStorage.getItem('refreshOption') || '',
appsOpen: false,
};
}
setZoom() {
this.setState({
zoomFontSize: Number(((localStorage.getItem('zoomNavbar') || 100) / 100) * 1.2) + 'rem',
});
}
updateRefreshText() {
let refreshText;
switch (localStorage.getItem('refreshOption')) {
case 'background':
refreshText = variables.getMessage('modals.main.settings.sections.background.title');
break;
case 'quote':
refreshText = variables.getMessage('modals.main.settings.sections.quote.title');
break;
case 'quotebackground':
refreshText =
variables.getMessage('modals.main.settings.sections.quote.title') +
' ' +
variables.getMessage('modals.main.settings.sections.background.title');
break;
default:
refreshText = variables.getMessage(
'modals.main.settings.sections.appearance.navbar.refresh_options.page',
);
break;
}
this.setState({
refreshText,
});
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'navbar' || data === 'background') {
this.setState({
refreshEnabled: localStorage.getItem('refresh'),
refreshOption: localStorage.getItem('refreshOption'),
});
try {
this.updateRefreshText();
this.setZoom();
} catch (e) {}
}
});
this.updateRefreshText();
this.setZoom();
}
componentWillUnmount() {
EventBus.off('refresh');
}
refresh() {
switch (this.state.refreshOption) {
case 'background':
return EventBus.emit('refresh', 'backgroundrefresh');
case 'quote':
return EventBus.emit('refresh', 'quoterefresh');
case 'quotebackground':
EventBus.emit('refresh', 'quoterefresh');
return EventBus.emit('refresh', 'backgroundrefresh');
default:
window.location.reload();
}
}
render() {
const backgroundEnabled = localStorage.getItem('background') === 'true';
const navbar = (
<div className="navbar-container">
<div className={this.state.classList}>
{localStorage.getItem('view') === 'true' && backgroundEnabled ? (
<Maximise fontSize={this.state.zoomFontSize} />
) : null}
{localStorage.getItem('notesEnabled') === 'true' && (
<Notes fontSize={this.state.zoomFontSize} />
)}
{localStorage.getItem('todoEnabled') === 'true' && (
<Todo fontSize={this.state.zoomFontSize} />
)}
{localStorage.getItem('appsEnabled') === 'true' && (
<Apps fontSize={this.state.zoomFontSize} />
)}
{this.refreshEnabled !== 'false' && (
<Tooltip
title={variables.getMessage('widgets.navbar.tooltips.refresh')}
subtitle={this.state.refreshText}
>
<button
onClick={() => this.refresh()}
style={{ fontSize: this.state.zoomFontSize }}
aria-label={variables.getMessage('widgets.navbar.tooltips.refresh')}
>
<MdRefresh className="refreshicon topicons" />
</button>
</Tooltip>
)}
<Tooltip
title={variables.getMessage('modals.main.navbar.settings', {
type: variables.getMessage(
'modals.main.navbar.tooltips.refresh_' + this.refreshValue,
),
})}
>
<button
onClick={() => this.props.openModal('mainModal')}
style={{ fontSize: this.state.zoomFontSize }}
aria-label={variables.getMessage('modals.main.navbar.settings', {
type: variables.getMessage(
'modals.main.navbar.tooltips.refresh_' + this.refreshValue,
),
})}
>
<MdSettings className="settings-icon topicons" />
</button>
</Tooltip>
</div>
</div>
);
return localStorage.getItem('navbarHover') === 'true' ? (
<div className="navbar-hover">{navbar}</div>
) : (
navbar
);
}
}
export default Navbar;

View File

@@ -0,0 +1,173 @@
import variables from 'config/variables';
import { PureComponent, memo, useState } from 'react';
import { MdContentCopy, MdAssignment, MdPushPin, MdDownload } from 'react-icons/md';
import { useFloating, shift } from '@floating-ui/react-dom';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import { toast } from 'react-toastify';
import { Tooltip } from 'components/Elements';
import { saveFile } from 'modules/helpers/settings/modals';
import EventBus from 'modules/helpers/eventbus';
class Notes extends PureComponent {
constructor() {
super();
this.state = {
notes: localStorage.getItem('notes') || '',
visibility: localStorage.getItem('notesPinned') === 'true' ? 'visible' : 'hidden',
showNotes: localStorage.getItem('notesPinned') === 'true' ? true : false,
};
}
setZoom() {
this.setState({
zoomFontSize: Number(((localStorage.getItem('zoomNavbar') || 100) / 100) * 1.2) + 'rem',
});
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'navbar') {
this.forceUpdate();
try {
this.setZoom();
} catch (e) {}
}
});
this.setZoom();
}
componentWillUnmount() {
EventBus.off('refresh');
}
setNotes = (e) => {
localStorage.setItem('notes', e.target.value);
this.setState({
notes: e.target.value,
});
};
showNotes() {
this.setState({
showNotes: true,
});
}
hideNotes() {
this.setState({
showNotes: localStorage.getItem('notesPinned') === 'true',
});
}
pin() {
variables.stats.postEvent('feature', 'Notes pin');
const notesPinned = localStorage.getItem('notesPinned') === 'true';
localStorage.setItem('notesPinned', !notesPinned);
this.setState({
showNotes: !notesPinned,
});
}
copy() {
variables.stats.postEvent('feature', 'Notes copied');
navigator.clipboard.writeText(this.state.notes);
toast(variables.getMessage('toasts.notes'));
}
download() {
const notes = localStorage.getItem('notes');
if (!notes || notes === '') {
return;
}
variables.stats.postEvent('feature', 'Notes download');
saveFile(this.state.notes, 'mue-notes.txt', 'text/plain');
}
render() {
return (
<div className="notes" onMouseLeave={() => this.hideNotes()} onFocus={() => this.showNotes()}>
<button
className="first"
onMouseEnter={() => this.showNotes()}
onFocus={() => this.showNotes()}
onBlur={() => this.hideNotes()}
ref={this.props.notesRef}
style={{ fontSize: this.state.zoomFontSize }}
aria-label={variables.getMessage('widgets.navbar.notes.title')}
>
<MdAssignment className="topicons" />
</button>
{this.state.showNotes && (
<span
className="notesContainer"
ref={this.props.floatRef}
style={{
position: this.props.position,
top: this.props.yPosition ?? '44',
left: this.props.xPosition ?? '',
}}
>
<div className="flexNotes">
<div className="topBarNotes" style={{ display: 'flex' }}>
<MdAssignment />
<span>{variables.getMessage('widgets.navbar.notes.title')}</span>
</div>
<div className="notes-buttons">
<Tooltip title={variables.getMessage('widgets.navbar.todo.pin')}>
<button onClick={() => this.pin()}>
<MdPushPin />
</button>
</Tooltip>
<Tooltip title={variables.getMessage('widgets.quote.copy')}>
<button onClick={() => this.copy()} disabled={this.state.notes === ''}>
<MdContentCopy />
</button>
</Tooltip>
<Tooltip title={variables.getMessage('widgets.background.download')}>
<button onClick={() => this.download()} disabled={this.state.notes === ''}>
<MdDownload />
</button>
</Tooltip>
</div>
<TextareaAutosize
placeholder={variables.getMessage('widgets.navbar.notes.placeholder')}
value={this.state.notes}
onChange={this.setNotes}
minRows={5}
maxLength={10000}
/>
</div>
</span>
)}
</div>
);
}
}
function NotesWrapper() {
const [reference, setReference] = useState(null);
const { x, y, refs, strategy } = useFloating({
placement: 'bottom',
middleware: [shift()],
elements: {
reference,
},
});
return (
<Notes
notesRef={setReference}
floatRef={refs.setFloating}
position={strategy}
xPosition={x}
yPosition={y}
/>
);
}
export default memo(NotesWrapper);

View File

@@ -0,0 +1,257 @@
import variables from 'config/variables';
import { PureComponent, memo, useState } from 'react';
import {
MdChecklist,
MdPushPin,
MdDelete,
MdPlaylistAdd,
MdOutlineDragIndicator,
MdPlaylistRemove,
} from 'react-icons/md';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import { Tooltip } from 'components/Elements';
import Checkbox from '@mui/material/Checkbox';
import { shift, useFloating } from '@floating-ui/react-dom';
import { sortableContainer, sortableElement, sortableHandle } from '@muetab/react-sortable-hoc';
import EventBus from 'modules/helpers/eventbus';
const SortableItem = sortableElement(({ value }) => <div>{value}</div>);
const SortableContainer = sortableContainer(({ children }) => <div>{children}</div>);
const SortableHandle = sortableHandle(() => <MdOutlineDragIndicator />);
class Todo extends PureComponent {
constructor() {
super();
this.state = {
todo: JSON.parse(localStorage.getItem('todo')) || [],
visibility: localStorage.getItem('todoPinned') === 'true' ? 'visible' : 'hidden',
marginLeft: localStorage.getItem('refresh') === 'false' ? '-200px' : '-130px',
showTodo: localStorage.getItem('todoPinned') === 'true',
};
}
setZoom() {
this.setState({
zoomFontSize: Number(((localStorage.getItem('zoomNavbar') || 100) / 100) * 1.2) + 'rem',
});
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'navbar') {
this.forceUpdate();
try {
this.setZoom();
} catch (e) {}
}
});
this.setZoom();
}
componentWillUnmount() {
EventBus.off('refresh');
}
/**
* It takes an array, removes an item from it, and then inserts it at a new index.
* @param {Array} array The array to move the item in.
* @param {Number} oldIndex The index of the item to move.
* @param {Number} newIndex The index to move the item to.
* @returns The result of the splice method.
*/
arrayMove(array, oldIndex, newIndex) {
const result = Array.from(array);
const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed);
return result;
}
onSortEnd = ({ oldIndex, newIndex }) => {
this.setState({
todo: this.arrayMove(this.state.todo, oldIndex, newIndex),
});
};
showTodo() {
this.setState({
showTodo: true,
});
}
hideTodo() {
this.setState({
showTodo: localStorage.getItem('todoPinned') === 'true',
});
}
/**
* This function takes in an action, an index, and data, and then updates the todo list accordingly.
* @param {String} action The action to perform. Can be 'add', 'remove', 'set', or 'done'.
* @param {Number} index The index of the item to perform the action on.
* @param {Object} data The data to use for the action.
*/
updateTodo(action, index, data) {
let todo = this.state.todo;
switch (action) {
case 'add':
todo.push({
value: '',
done: false,
});
break;
case 'remove':
todo.splice(index, 1);
break;
case 'set':
todo[index] = {
value: data.target.value,
done: todo[index].done,
};
break;
case 'done':
todo[index].done = !todo[index].done;
break;
default:
break;
}
localStorage.setItem('todo', JSON.stringify(todo));
this.setState({
todo,
});
this.forceUpdate();
}
pin() {
variables.stats.postEvent('feature', 'Todo pin');
const todoPinned = localStorage.getItem('todoPinned') === 'true';
localStorage.setItem('todoPinned', !todoPinned);
this.setState({
showTodo: !todoPinned,
});
}
render() {
return (
<div className="notes" onMouseLeave={() => this.hideTodo()} onFocus={() => this.showTodo()}>
<button
className="first"
onMouseEnter={() => this.showTodo()}
onFocus={() => this.hideTodo()}
onBlur={() => this.showTodo()}
ref={this.props.todoRef}
style={{ fontSize: this.state.zoomFontSize }}
>
<MdChecklist className="topicons" />
</button>
{this.state.showTodo && (
<span
className="notesContainer"
ref={this.props.floatRef}
style={{
position: this.props.position,
top: this.props.yPosition ?? '44px',
left: this.props.xPosition ?? '',
}}
>
<div className="flexTodo">
<div className="topBarNotes" style={{ display: 'flex' }}>
<MdChecklist />
<span>{variables.getMessage('widgets.navbar.todo.title')}</span>
</div>
<div className="notes-buttons">
<Tooltip title={variables.getMessage('widgets.navbar.todo.pin')}>
<button onClick={() => this.pin()}>
<MdPushPin />
</button>
</Tooltip>
<Tooltip title={variables.getMessage('widgets.navbar.todo.add')}>
<button onClick={() => this.updateTodo('add')}>
<MdPlaylistAdd />
</button>
</Tooltip>
</div>
<div className={'todoRows'}>
{this.state.todo.length === 0 ? (
<div className="todosEmpty">
<div className="emptyNewMessage">
<MdPlaylistRemove />
<span className="title">
{variables.getMessage('widgets.navbar.todo.no_todos')}
</span>
<span className="subtitle">
{variables.getMessage('modals.main.settings.sections.message.add_some')}
</span>
</div>
</div>
) : (
<SortableContainer
onSortEnd={this.onSortEnd}
lockAxis="y"
lockToContainerEdges
disableAutoscroll
useDragHandle
>
{this.state.todo.map((_value, index) => (
<SortableItem
key={`item-${index}`}
index={index}
value={
<div
className={'todoRow' + (this.state.todo[index].done ? ' done' : '')}
key={index}
>
<Checkbox
checked={this.state.todo[index].done}
onClick={() => this.updateTodo('done', index)}
/>
<TextareaAutosize
placeholder={variables.getMessage('widgets.navbar.notes.placeholder')}
value={this.state.todo[index].value}
onChange={(data) => this.updateTodo('set', index, data)}
readOnly={this.state.todo[index].done}
/>
<MdDelete onClick={() => this.updateTodo('remove', index)} />
<SortableHandle />
</div>
}
/>
))}
</SortableContainer>
)}
</div>
</div>
</span>
)}
</div>
);
}
}
function TodoWrapper() {
const [reference, setReference] = useState(null);
const { x, y, refs, strategy } = useFloating({
placement: 'bottom',
middleware: [shift()],
elements: {
reference,
},
});
return (
<Todo
todoRef={setReference}
floatRef={refs.setFloating}
position={strategy}
xPosition={x}
yPosition={y}
/>
);
}
export default memo(TodoWrapper);

View File

@@ -0,0 +1,80 @@
@import 'scss/variables';
$appsWidth: 21rem;
.appsShortcutContainer {
max-height: 35rem;
overflow-y: auto;
// scrollbar-width: thin;
border-radius: 0.8em;
padding: 1.2em;
display: grid;
grid-template-columns: repeat(3, auto);
grid-auto-rows: 100px;
gap: 10px;
place-items: center;
@include themed {
background: t($modal-secondaryColour);
}
.noAppsContainer {
grid-column: 1/3;
h3 {
margin: 0;
display: flex;
align-items: center;
}
svg {
font-size: 30px;
margin-left: 10px;
}
}
}
.appsIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-decoration: none;
padding: 10px;
border-radius: 0.8em;
cursor: pointer;
width: 5rem;
height: 4.7rem;
transition: 0.5s;
img {
border-radius: 0.6rem;
}
span {
display: inline-block;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-align: center;
margin-top: 10px;
}
&:hover {
span {
white-space: initial;
}
height: max-content;
}
@include themed {
color: t($color);
&:hover {
background: t($modal-sidebarActive);
}
}
}

View File

@@ -0,0 +1,125 @@
@import 'scss/variables';
.notes {
position: relative;
h3 {
text-shadow: none;
margin: 0;
cursor: initial;
user-select: none;
}
&:hover .notesContainer {
visibility: visible !important;
}
&:active .notesContainer {
visibility: visible !important;
}
&:focus .notesContainer {
visibility: visible !important;
}
}
.notesContainer {
@extend %basic;
padding: 15px;
text-align: center;
border-radius: 12px;
position: absolute;
font-size: 1rem !important;
/* top: 100%;
left: 50%;
margin-left: -130px; */
.notes-buttons {
button {
@include basicIconButton(11px, 1.3rem, ui);
@include themed {
background: t($btn-background) !important;
&:hover {
background: t($btn-backgroundHover) !important;
}
}
flex-grow: 1;
display: grid;
place-items: center;
svg {
font-size: 1.3rem;
}
}
}
.flexNotes {
display: flex;
flex-flow: column;
gap: 15px;
textarea {
max-height: 65vh !important;
overflow-y: visible !important;
@extend %basic;
border: none;
padding: 15px;
border-radius: 12px;
width: 200px;
}
}
::placeholder {
color: #636e72;
opacity: 1;
}
}
textarea {
border: none;
resize: none;
background: none;
}
.topBarNotes {
@extend %basic;
display: flex;
align-items: center;
justify-content: center;
height: 50px;
border-radius: 12px;
flex-flow: row;
gap: 5px;
user-select: none;
svg {
font-size: 1.5rem;
}
}
.notes-buttons {
display: flex !important;
gap: 10px;
button {
&:disabled {
@include themed {
background: t($modal-sidebar) !important;
}
cursor: not-allowed;
}
}
.tooltip {
flex: 1 !important;
}
}

View File

@@ -0,0 +1,69 @@
.notesContainer {
.flexTodo {
display: flex;
flex-flow: column;
gap: 15px;
width: 223px;
.todoRows {
max-height: 65vh !important;
overflow: hidden visible !important;
display: flex;
flex-flow: column;
gap: 15px;
}
}
.todosEmpty {
height: 200px;
display: grid;
place-items: center;
}
}
.todoRow {
@include basicIconButton(1px, 16px, ui);
display: flex;
flex-flow: row;
align-items: center;
margin: 15px 5px;
@include themed {
color: t($color) !important;
}
textarea {
width: 120px;
@include themed {
color: t($color) !important;
}
}
svg {
padding: 10px;
place-items: center;
display: grid;
cursor: pointer;
@include themed {
color: t($color) !important;
}
&:hover {
@include themed {
background: t($modal-sidebar);
border-radius: t($borderRadius);
}
}
}
}
.done {
text-decoration: line-through;
textarea {
cursor: not-allowed;
}
}

View File

@@ -0,0 +1,96 @@
@import 'notes';
@import 'todo';
@import 'apps';
@import 'scss/variables';
.navbar {
display: flex;
flex-flow: row;
gap: 10px;
animation: fadeIn 2s;
button {
-webkit-font-smoothing: subpixel-antialiased;
/* filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.3)); */
cursor: pointer;
// transition: 0.2s ease;
margin-top: 0;
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* &:hover {
color: map-get($theme-colours, 'main-text-color');
transform: translateZ(0);
transform: scale(1.3);
} */
}
}
.old {
.tooltip {
button {
@include basicIconButton(12px, 1.2rem, legacy);
}
}
.first {
@include basicIconButton(12px, 1.2rem, legacy);
}
}
.new {
button {
@include basicIconButton(12px, 1.2rem, ui);
}
}
.navbar-hover {
position: absolute;
top: 0;
right: 0;
height: 50px;
width: 500px;
.navbar {
opacity: 0;
transition:
visibility 0.2s linear,
opacity 0.2s linear;
visibility: hidden;
}
&:hover {
.navbar {
opacity: 1;
visibility: visible;
}
}
}
.navbar-container {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-flow: column;
}
.appsmodal {
width: fit-content;
padding: 5px;
border-radius: 1em;
@include themed {
background: t($modal-sidebarActive);
}
}

View File

@@ -0,0 +1,121 @@
import { PureComponent, createRef } from 'react';
import { Tooltip } from 'components/Elements';
import EventBus from 'modules/helpers/eventbus';
import './quicklinks.scss';
export default class QuickLinks extends PureComponent {
constructor() {
super();
this.state = {
items: JSON.parse(localStorage.getItem('quicklinks')),
};
this.quicklinksContainer = createRef();
}
// widget zoom
setZoom(element) {
const zoom = localStorage.getItem('zoomQuicklinks') || 100;
for (const link of element.getElementsByTagName('span')) {
link.style.fontSize = `${14 * Number(zoom / 100)}px`;
}
if (localStorage.getItem('quickLinksStyle') !== 'text') {
for (const img of element.getElementsByTagName('img')) {
img.style.height = `${30 * Number(zoom / 100)}px`;
}
}
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'quicklinks') {
if (localStorage.getItem('quicklinksenabled') === 'false') {
return (this.quicklinksContainer.current.style.display = 'none');
}
this.quicklinksContainer.current.style.display = 'block';
this.setZoom(this.quicklinksContainer.current);
this.setState({
items: JSON.parse(localStorage.getItem('quicklinks')),
});
}
});
this.setZoom(this.quicklinksContainer.current);
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
let target,
rel = null;
if (localStorage.getItem('quicklinksnewtab') === 'true') {
target = '_blank';
rel = 'noopener noreferrer';
}
const tooltipEnabled = localStorage.getItem('quicklinkstooltip');
const quickLink = (item) => {
if (localStorage.getItem('quickLinksStyle') === 'text') {
return (
<a
className="quicklinkstext"
key={item.key}
href={item.url}
target={target}
rel={rel}
draggable={false}
>
{item.name}
</a>
);
}
const img =
item.icon ||
'https://icon.horse/icon/ ' + item.url.replace('https://', '').replace('http://', '');
if (localStorage.getItem('quickLinksStyle') === 'metro') {
return (
<a
className="quickLinksMetro"
key={item.key}
href={item.url}
target={target}
rel={rel}
draggable={false}
>
<img src={img} alt={item.name} draggable={false} />
<span className="subtitle">{item.name}</span>
</a>
);
}
const link = (
<a key={item.key} href={item.url} target={target} rel={rel} draggable={false}>
<img src={img} alt={item.name} draggable={false} />
</a>
);
return tooltipEnabled === 'true' ? (
<Tooltip title={item.name} placement="bottom" key={item.key}>
{link}
</Tooltip>
) : (
link
);
};
return (
<div className="quicklinkscontainer" ref={this.quicklinksContainer}>
{this.state.items.map((item) => quickLink(item))}
</div>
);
}
}

View File

@@ -0,0 +1,258 @@
@import 'scss/variables';
.quicklinks {
@include basicIconButton(10px, 14px, ui);
outline: none;
border: none;
box-shadow: 0 0 0 1px #484848;
border-radius: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: space-evenly;
font-size: 0.5em;
padding: 10px 40px;
gap: 15px;
}
.quicklinkscontainer {
border-radius: 12px;
z-index: 1;
display: flex;
place-content: center center;
gap: 12px;
textarea {
@extend %basic;
border: none;
padding: 15px;
border-radius: 12px;
}
::placeholder {
color: #636e72;
opacity: 1;
}
}
.topbarquicklinks {
svg {
font-size: 46px;
padding: 9px;
}
h4 {
margin: 0;
cursor: initial;
user-select: none;
text-shadow: none;
}
p {
font-size: 16px;
cursor: initial;
user-select: none;
color: rgb(255 71 87 / 100%);
}
}
.quicklinks-container > a,
.quicklinks-container > .quicklinks > button {
display: inline;
}
.quicklinks-container {
img {
height: 32px;
width: auto;
transition: transform 0.2s;
user-select: none;
&:hover {
transform: scale(1.1);
}
}
a {
margin: 5px;
}
}
.quicklinkstext {
text-decoration: none;
color: white;
font-size: 0.8em;
&:hover {
text-decoration: underline;
}
}
/* background-color: var(--background);
color: var(--modal-text); */
.quicklinksdropdown {
@extend %basic;
position: absolute;
z-index: 99;
display: none;
flex-flow: column;
padding: 15px;
box-shadow: 0 0 0 1px #484848;
gap: 5px;
&:hover {
.quicklinksdropdown {
display: block;
}
}
.dropdown-title {
font-size: 0.9em;
}
.dropdown-subtitle {
font-size: 0.4em;
@include themed {
color: t($subColor);
}
}
button {
@include basicIconButton(10px, 0.9rem, ui);
border-radius: 12px;
border: none;
outline: none;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
}
.dropdown-error {
font-size: 0.4em;
}
button.quicklinks {
cursor: pointer;
}
.outOfScreen {
bottom: 515px;
}
.addLinkModal {
@extend %tabText;
padding: 15px;
@include themed {
background: t($modal-secondaryColour);
}
button {
@include modal-button(standard);
padding: 10px 30px;
float: right;
}
.addFooter {
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
}
.dropdown-error {
font-size: 16px;
padding-left: 5px;
color: #e74c3c;
}
}
.quicklinkModalTextbox {
display: grid;
grid-template-rows: auto 1fr; /* Two rows: first auto-sized, second filling remaining space */
grid-template-columns: repeat(2, 1fr); /* Two equal-width columns for the first row */
grid-gap: 10px; /* Optional gap between items */
padding: 15px 0;
button {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: center;
}
.dropdown-error {
font-size: 30px;
padding-left: 5px;
color: #e74c3c;
}
@include themed {
textarea {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
color: t($color);
border-radius: t($borderRadius);
padding: 10px 20px;
&:hover {
box-shadow: 0 0 0 1px t($color);
}
&:active {
box-shadow: 0 0 0 1px t($color);
}
&:focus {
box-shadow: 0 0 0 1px t($color);
}
}
}
}
.quickLinksMetro {
@extend %basic;
text-decoration: none;
gap: 10px;
display: flex;
flex-flow: column;
align-items: center;
min-width: 100px;
background-image: linear-gradient(to left, rgb(0 0 0), transparent, rgb(0 0 0)),
url('https://media.cntraveller.com/photos/615ee85…/16:9/w_2580,c_limit/Best%20Cities%20in%20the%20World%20-%20Grid.jpg');
transition: 0.8s;
text-align: left;
padding: 20px 40px;
@include themed {
&:hover {
background: t($btn-backgroundHover);
}
}
img {
width: auto;
align-self: center;
}
}
.quicklinknostyle {
text-decoration: none;
font-size: 14px;
@include themed {
color: t($subColor);
}
}

View File

@@ -0,0 +1,516 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import {
MdContentCopy,
MdStarBorder,
MdStar,
MdPerson,
MdOpenInNew,
MdIosShare,
} from 'react-icons/md';
import { toast } from 'react-toastify';
import { Tooltip } from 'components/Elements';
import Modal from 'react-modal';
import { ShareModal } from 'components/Elements';
import offline_quotes from './offline_quotes.json';
import EventBus from 'modules/helpers/eventbus';
import './quote.scss';
export default class Quote extends PureComponent {
buttons = {
share: (
<Tooltip title={variables.getMessage('widgets.quote.share')}>
<button
onClick={() => this.setState({ shareModal: true })}
aria-label={variables.getMessage('widgets.quote.share')}
>
<MdIosShare className="copyButton" />
</button>
</Tooltip>
),
copy: (
<Tooltip title={variables.getMessage('widgets.quote.copy')}>
<button
onClick={() => this.copyQuote()}
aria-label={variables.getMessage('widgets.quote.copy')}
>
<MdContentCopy className="copyButton" />
</button>
</Tooltip>
),
unfavourited: (
<Tooltip title={variables.getMessage('widgets.quote.favourite')}>
<button
onClick={() => this.favourite()}
aria-label={variables.getMessage('widgets.quote.favourite')}
>
<MdStarBorder className="copyButton" />
</button>
</Tooltip>
),
favourited: (
<Tooltip title={variables.getMessage('widgets.quote.unfavourite')}>
<button
onClick={() => this.favourite()}
aria-label={variables.getMessage('widgets.quote.unfavourite')}
>
<MdStar className="copyButton" />
</button>
</Tooltip>
),
};
constructor() {
super();
this.state = {
quote: '',
author: '',
authorOccupation: '',
favourited: this.useFavourite(),
share: localStorage.getItem('quoteShareButton') === 'false' ? null : this.buttons.share,
copy: localStorage.getItem('copyButton') === 'false' ? null : this.buttons.copy,
quoteLanguage: '',
type: localStorage.getItem('quoteType') || 'api',
shareModal: false,
};
this.quote = createRef();
this.quotediv = createRef();
this.quoteauthor = createRef();
}
useFavourite() {
if (localStorage.getItem('favouriteQuoteEnabled') === 'true') {
return localStorage.getItem('favouriteQuote')
? this.buttons.favourited
: this.buttons.unfavourited;
} else {
return null;
}
}
doOffline() {
// Get a random quote from our local JSON
const quote = offline_quotes[Math.floor(Math.random() * offline_quotes.length)];
this.setState({
quote: '"' + quote.quote + '"',
author: quote.author,
authorlink: this.getAuthorLink(quote.author),
authorimg: '',
});
}
getAuthorLink(author) {
return localStorage.getItem('authorLink') === 'false' || author === 'Unknown'
? null
: `https://${variables.languagecode.split('_')[0]}.wikipedia.org/wiki/${author
.split(' ')
.join('_')}`;
}
stripHTML(html) {
const tmpdoc = new DOMParser().parseFromString(html, 'text/html');
return tmpdoc.body.textContent || '';
}
async getAuthorImg(author) {
if (localStorage.getItem('authorImg') === 'false') {
return {
authorimg: null,
authorimglicense: null,
};
}
const authorimgdata = await (
await fetch(
`https://${
variables.languagecode.split('_')[0]
}.wikipedia.org/w/api.php?action=query&titles=${author}&origin=*&prop=pageimages&format=json&pithumbsize=100`,
)
).json();
let authorimg, authorimglicense;
try {
authorimg =
authorimgdata.query.pages[Object.keys(authorimgdata.query.pages)[0]].thumbnail.source;
const authorimglicensedata = await (
await fetch(
`https://${
variables.languagecode.split('_')[0]
}.wikipedia.org/w/api.php?action=query&prop=imageinfo&iiprop=extmetadata&titles=File:${
authorimgdata.query.pages[Object.keys(authorimgdata.query.pages)[0]].pageimage
}&origin=*&format=json`,
)
).json();
const metadata =
authorimglicensedata.query.pages[Object.keys(authorimglicensedata.query.pages)[0]]
.imageinfo[0].extmetadata;
const license = metadata.LicenseShortName;
const photographer =
metadata.Attribution.value ||
this.stripHTML(metadata.Artist?.value || '').replace(/ \(talk\)/, '') || // talk page link (if applicable) is only removed for English
'Unknown';
authorimglicense = `© ${photographer}. ${license.value}`;
authorimglicense = authorimglicense.replace(/copyright\s/i, '').replace(/©\s©\s/, '© ');
if (license.value === 'Public domain') {
authorimglicense = null;
} else if (photographer.value === 'Unknown' || !photographer) {
authorimglicense = null;
authorimg = null;
}
} catch (e) {
authorimg = null;
authorimglicense = null;
}
if (author === 'Unknown') {
authorimg = null;
authorimglicense = null;
}
return {
authorimg,
authorimglicense,
};
}
async getQuote() {
const offline = localStorage.getItem('offlineMode') === 'true';
const favouriteQuote = localStorage.getItem('favouriteQuote');
if (favouriteQuote) {
let author = favouriteQuote.split(' - ')[1];
const authorimgdata = await this.getAuthorImg(author);
return this.setState({
quote: favouriteQuote.split(' - ')[0],
author,
authorlink: this.getAuthorLink(author),
authorimg: authorimgdata.authorimg,
authorimglicense: authorimgdata.authorimglicense,
});
}
switch (this.state.type) {
case 'custom':
let customQuote;
try {
customQuote = JSON.parse(localStorage.getItem('customQuote'));
} catch (e) {
// move to new format
customQuote = [
{
quote: localStorage.getItem('customQuote'),
author: localStorage.getItem('customQuoteAuthor'),
},
];
localStorage.setItem('customQuote', JSON.stringify(customQuote));
}
// pick random
customQuote = customQuote
? customQuote[Math.floor(Math.random() * customQuote.length)]
: null;
if (customQuote !== undefined) {
return this.setState({
quote: '"' + customQuote.quote + '"',
author: customQuote.author,
authorlink: this.getAuthorLink(customQuote.author),
authorimg: await this.getAuthorImg(customQuote.author),
noQuote: false,
});
} else {
this.setState({
noQuote: true,
});
}
break;
case 'quote_pack':
if (offline) {
return this.doOffline();
}
const quotePack = [];
const installed = JSON.parse(localStorage.getItem('installed'));
installed.forEach((item) => {
if (item.type === 'quotes') {
const quotes = item.quotes.map((quote) => ({
...quote,
fallbackauthorimg: item.icon_url,
}));
quotePack.push(...quotes);
}
});
if (quotePack) {
const data = quotePack[Math.floor(Math.random() * quotePack.length)];
return this.setState({
quote: '"' + data.quote + '"',
author: data.author,
authorlink: this.getAuthorLink(data.author),
authorimg: data.fallbackauthorimg,
});
} else {
return this.doOffline();
}
case 'api':
if (offline) {
return this.doOffline();
}
const getAPIQuoteData = async () => {
const quoteLanguage = localStorage.getItem('quoteLanguage');
const data = await (
await fetch(variables.constants.API_URL + '/quotes/random?language=' + quoteLanguage)
).json();
// If we hit the ratelimit, we fall back to local quotes
if (data.statusCode === 429) {
return null;
}
const authorimgdata = await this.getAuthorImg(data.author);
return {
quote: '"' + data.quote.replace(/\s+$/g, '') + '"',
author: data.author,
authorlink: this.getAuthorLink(data.author),
authorimg: authorimgdata.authorimg,
authorimglicense: authorimgdata.authorimglicense,
quoteLanguage: quoteLanguage,
authorOccupation: data.author_occupation,
};
};
// First we try and get a quote from the API...
try {
let data = JSON.parse(localStorage.getItem('nextQuote')) || (await getAPIQuoteData());
localStorage.setItem('nextQuote', null);
if (data) {
this.setState(data);
localStorage.setItem('currentQuote', JSON.stringify(data));
localStorage.setItem('nextQuote', JSON.stringify(await getAPIQuoteData())); // pre-fetch data about the next quote
} else {
this.doOffline();
}
} catch (e) {
// ...and if that fails we load one locally
this.doOffline();
}
break;
default:
break;
}
}
copyQuote() {
variables.stats.postEvent('feature', 'Quote copied');
navigator.clipboard.writeText(`${this.state.quote} - ${this.state.author}`);
toast(variables.getMessage('toasts.quote'));
}
favourite() {
if (localStorage.getItem('favouriteQuote')) {
localStorage.removeItem('favouriteQuote');
this.setState({
favourited: this.buttons.unfavourited,
});
} else {
localStorage.setItem('favouriteQuote', this.state.quote + ' - ' + this.state.author);
this.setState({
favourited: this.buttons.favourited,
});
}
variables.stats.postEvent('feature', 'Quote favourite');
}
init() {
this.setZoom();
const quoteType = localStorage.getItem('quoteType');
if (
this.state.type !== quoteType ||
localStorage.getItem('quoteLanguage') !== this.state.quoteLanguage ||
(quoteType === 'custom' && this.state.quote !== localStorage.getItem('customQuote')) ||
(quoteType === 'custom' && this.state.author !== localStorage.getItem('customQuoteAuthor'))
) {
this.getQuote();
}
}
setZoom() {
const zoomQuote = Number((localStorage.getItem('zoomQuote') || 100) / 100);
this.quote.current.style.fontSize = `${0.8 * zoomQuote}em`;
this.quoteauthor.current.style.fontSize = `${0.9 * zoomQuote}em`;
}
componentDidMount() {
// const test = localStorage.getItem('quotechange');
// this.interval = setInterval(() => {
// if (test !== null) {
// const targetTime = Number(
// Number(localStorage.getItem('quoteStartTime')) +
// Number(localStorage.getItem('quotechange')),
// );
// const currentTime = Number(Date.now());
// if (currentTime >= targetTime) {
// this.setZoom();
// this.getQuote();
// localStorage.setItem('quoteStartTime', Date.now());
// } else {
// try {
// this.setState(JSON.parse(localStorage.getItem('currentQuote')));
// } catch (e) {
// this.setZoom();
// this.getQuote();
// }
// }
// }
// });
this.setZoom();
EventBus.on('refresh', (data) => {
if (data === 'quote') {
if (localStorage.getItem('quote') === 'false') {
return (this.quotediv.current.style.display = 'none');
}
this.quotediv.current.style.display = 'block';
this.init();
// buttons hot reload
this.setState({
favourited: this.useFavourite(),
share: localStorage.getItem('quoteShareButton') === 'false' ? null : this.buttons.share,
copy: localStorage.getItem('copyButton') === 'false' ? null : this.buttons.copy,
});
}
// uninstall quote pack reverts the quote to what you had previously
if (data === 'marketplacequoteuninstall') {
this.init();
}
if (data === 'quoterefresh') {
this.getQuote();
}
});
if (
localStorage.getItem('quotechange') === 'refresh' ||
localStorage.getItem('quotechange') === null
) {
this.setZoom();
this.getQuote();
localStorage.setItem('quoteStartTime', Date.now());
}
}
componentWillUnmount() {
EventBus.off('refresh');
clearInterval(this.interval);
}
render() {
if (this.state.noQuote === true) {
return <></>;
}
return (
<div className="quotediv" ref={this.quotediv}>
<Modal
closeTimeoutMS={300}
isOpen={this.state.shareModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => this.setState({ shareModal: false })}
>
<ShareModal
data={`${this.state.quote} - ${this.state.author}`}
modalClose={() => this.setState({ shareModal: false })}
/>
</Modal>
<span className="quote" ref={this.quote}>
{this.state.quote}
</span>
{localStorage.getItem('widgetStyle') === 'legacy' ? (
<>
<div>
<h1 className="quoteauthor" ref={this.quoteauthor}>
<a
href={this.state.authorlink}
className="quoteAuthorLink"
target="_blank"
rel="noopener noreferrer"
aria-label="Learn about the author of the quote."
>
{this.state.author}
</a>
</h1>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
{this.state.copy} {this.state.share} {this.state.favourited}
</div>
</>
) : (
<div className="author-holder">
<div className="author">
<div
className="author-img"
style={{ backgroundImage: `url(${this.state.authorimg})` }}
>
{this.state.authorimg === undefined || this.state.authorimg ? '' : <MdPerson />}
</div>
{this.state.author !== '' ? (
<div className="author-content" ref={this.quoteauthor}>
<span className="title">{this.state.author}</span>
{this.state.authorOccupation !== 'Unknown' && (
<span className="subtitle">{this.state.authorOccupation}</span>
)}
<span className="author-license">
{this.state.authorimglicense &&
this.state.authorimglicense.replace(' undefined. ', ' ')}
</span>
</div>
) : (
<div className="author-content whileLoading" ref={this.quoteauthor}>
{/* these are placeholders for skeleton and as such don't need translating */}
<span className="title">loading</span>
<span className="subtitle">loading</span>
</div>
)}
<div className="quote-buttons">
{this.state.authorOccupation !== 'Unknown' && this.state.authorlink !== '' ? (
<Tooltip title={variables.getMessage('widgets.quote.link_tooltip')}>
<a
href={this.state.authorlink}
className="quoteAuthorLink"
target="_blank"
rel="noopener noreferrer"
aria-label="Learn about the author of the quote."
>
<MdOpenInNew />
</a>{' '}
</Tooltip>
) : null}
{this.state.copy} {this.state.share} {this.state.favourited}
</div>
</div>
</div>
)}
</div>
);
}
}

View File

@@ -0,0 +1,102 @@
[
{
"author": "Robert De Niro",
"quote": "Time goes on. So whatever youre going to do, do it. Do it now. Dont wait."
},
{
"author": "Walt Disney",
"quote": "All our dreams can come true, if we have the courage to pursue them."
},
{
"author": "Confucius",
"quote": "It does not matter how slowly you go as long as you do not stop."
},
{
"author": "Roy T. Bennett",
"quote": "Believe in yourself. You are braver than you think, more talented than you know, and capable of more than you imagine."
},
{
"author": "Wayne Dyer",
"quote": "If you believe it will work out, youll see opportunities. If you believe it wont, you will see obstacles."
},
{
"author": "George Addair",
"quote": "Everything youve ever wanted is on the other side of fear."
},
{
"author": "Winston Churchill",
"quote": "Success is not final, failure is not fatal: it is the courage to continue that counts."
},
{
"author": "Paulo Coelho",
"quote": "There is only one thing that makes a dream impossible to achieve: the fear of failure"
},
{
"author": "Brian Tracy",
"quote": "Your true success in life begins only when you make the commitment to become excellent at what you do."
},
{
"author": "Chantal Sutherland",
"quote": "Believe in yourself, take on your challenges, dig deep within yourself to conquer fears. Never let anyone bring you down. You got to keep going."
},
{
"author": "Les Brown",
"quote": "Too many of us are not living our dreams because we are living our fears."
},
{
"author": "Bob Riley",
"quote": "Hard times dont create heroes. It is during the hard times when the hero within us is revealed."
},
{
"author": "Jack Canfield",
"quote": "If you can tune into your purpose and really align with it, setting goals so that your vision is an expression of that purpose, then life flows much more easily."
},
{
"author": "Napolean Hill",
"quote": "Whatever the mind can conceive and believe, it can achieve."
},
{
"author": "Jim Rohn",
"quote": "Dont wish it were easier. Wish you were better."
},
{
"author": "Serena Williams",
"quote": "A champion is defined not by their wins but by how they can recover when they fall."
},
{
"author": "Sheryl Sandberg",
"quote": "Motivation comes from working on things we care about."
},
{
"author": "Reese Witherspoon",
"quote": "With the right kind of coaching and determination you can accomplish anything."
},
{
"author": "Hazrat Inayat Khan",
"quote": "Some people look for a beautiful place. Others make a place beautiful."
},
{
"author": "Albert Einstein",
"quote": "Life is like riding a bicycle. To keep your balance, you must keep moving."
},
{
"author": "Walt Disney",
"quote": "The way to get started is to quit talking and begin doing."
},
{
"author": "Winston Churchill",
"quote": "A pessimist sees the difficulty in every opportunity; an optimist sees the opportunity in every difficulty."
},
{
"author": "Will Rogers",
"quote": "Don't let yesterday take up too much of today."
},
{
"author": "Vince Lombardi",
"quote": "It's not whether you get knocked down, it's whether you get up."
},
{
"author": "Steve Jobs",
"quote": "If you are working on something that you really care about, you dont have to be pushed. The vision pulls you."
}
]

View File

@@ -0,0 +1,168 @@
@import 'scss/variables';
.quote {
font-size: 0.8em;
text-shadow: 0 0 10px rgb(0 0 0 / 30%);
cursor: initial;
user-select: none;
--shadow-shift: 0.125rem;
color: #fff;
font-weight: 600;
width: 40vw;
}
.quoteauthor {
font-size: 0.9em;
letter-spacing: 0.5px;
user-select: none;
--shadow-shift: 0.125rem;
svg {
margin-left: 10px;
filter: drop-shadow(0 0 6px rgb(0 0 0 / 30%));
}
}
i.material-icons,
h1.quoteauthor {
display: inline;
}
.quoteAuthorLink {
text-decoration: none;
color: white;
user-select: none;
}
.author {
@extend %basic;
display: flex;
flex-flow: row;
height: 70px;
font-weight: 300;
margin: 10px;
}
.author-name {
font-size: clamp(15px, 2.5vw, 0.6em);
}
.author-knownfor {
font-size: clamp(13px, 2.5vw, 0.4em);
@include themed {
color: t($subColor);
}
}
.author-license {
font-size: clamp(8px, 2.5vw, 0.1em);
@include themed {
color: t($subColor);
}
}
.author img {
height: 100%;
width: auto;
border-radius: 12px 0 0 12px;
}
.author-img {
@extend %basic;
height: 100%;
width: 70px;
border-radius: 12px 0 0 12px;
display: flex;
align-items: center;
justify-content: center;
background-size: cover !important;
background-repeat: no-repeat !important;
background-position: initial;
}
.author-content {
display: flex;
flex-flow: column;
justify-content: center;
align-items: flex-start;
padding: 20px;
text-align: left;
}
.author-holder {
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
animation: fadeIn 1s;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.quote-buttons {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 20px 0;
a {
display: flex;
}
a {
@include basicIconButton(11px, 1.3rem, ui);
}
}
.quotediv {
animation: fadeIn 1s;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
button {
@include basicIconButton(11px, 1.3rem, ui);
}
}
.deleteButton {
height: auto !important;
@include basicIconButton(11px, 1.3rem, modal);
padding: 10px 20px;
}
.author-content.whileLoading {
@include themed {
gap: 5px;
.title {
color: transparent;
width: 100px;
background: t($modal-sidebar);
}
.subtitle {
color: transparent;
width: 50px;
background: t($modal-sidebarActive);
}
}
}

View File

@@ -0,0 +1,282 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import { MdSearch, MdMic, MdScreenSearchDesktop } from 'react-icons/md';
import { BsGoogle } from 'react-icons/bs';
import { SiDuckduckgo, SiMicrosoftbing, SiYahoo, SiBaidu } from 'react-icons/si';
import { FaYandex } from 'react-icons/fa';
import { Tooltip } from 'components/Elements';
import AutocompleteInput from 'features/helpers/autocomplete/Autocomplete';
import EventBus from 'modules/helpers/eventbus';
import './search.scss';
import searchEngines from 'features/widgets/search/search_engines.json';
export default class Search extends PureComponent {
constructor() {
super();
this.state = {
url: '',
query: '',
microphone: null,
suggestions: [],
searchDropdown: false,
classList:
localStorage.getItem('widgetStyle') === 'legacy' ? 'searchIcons old' : 'searchIcons',
};
this.micIcon = createRef();
}
startSpeechRecognition = () => {
const voiceSearch = new window.webkitSpeechRecognition();
voiceSearch.start();
this.micIcon.current.classList.add('micActive');
const searchText = document.getElementById('searchtext');
voiceSearch.onresult = (event) => {
searchText.value = event.results[0][0].transcript;
};
voiceSearch.onend = () => {
this.micIcon.current.classList.remove('micActive');
if (searchText.value === '') {
return;
}
setTimeout(() => {
variables.stats.postEvent('feature', 'Voice search');
window.location.href = this.state.url + `?${this.state.query}=` + searchText.value;
}, 1000);
};
};
searchButton = (e) => {
e.preventDefault();
const value = e.target.value || document.getElementById('searchtext').value || 'mue fast';
variables.stats.postEvent('feature', 'Search');
window.location.href = this.state.url + `?${this.state.query}=` + value;
};
async getSuggestions(input) {
window.setResults = (results) => {
window.searchResults = results;
};
const results = await (await fetch(`https://ac.ecosia.org/?q=${input}`)).json();
try {
this.setState({
suggestions: results.suggestions.splice(0, 3),
});
} catch (e) {
// ignore error if empty
}
}
init() {
let url, microphone;
let query = 'q';
const setting = localStorage.getItem('searchEngine');
const info = searchEngines.find((i) => i.settingsName === setting);
if (info !== undefined) {
url = info.url;
if (info.query) {
query = info.query;
}
}
if (setting === 'custom') {
const custom = localStorage.getItem('customSearchEngine');
if (custom !== null) {
url = custom;
}
}
if (localStorage.getItem('voiceSearch') === 'true') {
microphone = (
<button
onClick={this.startSpeechRecognition}
ref={this.micIcon}
aria-label="Microphone Search"
>
<MdMic className="micIcon" />
</button>
);
}
this.setState({
url,
query,
microphone,
currentSearch: info ? info.name : 'Custom',
});
}
/**
* If the user selects a search engine from the dropdown menu, the function will set the state of the
* search engine to the selected search engine.
* @param {string} name - The name of the search engine
* @param {boolean} custom - If the search engine is custom
*/
setSearch(name, custom) {
let url;
let query = 'q';
const info = searchEngines.find((i) => i.name === name);
if (info !== undefined) {
url = info.url;
if (info.query) {
query = info.query;
}
}
if (custom) {
const customSetting = localStorage.getItem('customSearchEngine');
if (customSetting !== null) {
url = customSetting;
} else {
url = this.state.url;
}
} else {
localStorage.setItem('searchEngine', info.settingsName);
}
this.setState({
url,
query,
currentSearch: name,
searchDropdown: false,
});
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'search') {
this.init();
}
});
this.init();
if (localStorage.getItem('searchFocus') === 'true') {
const element = document.getElementById('searchtext');
if (element) {
element.focus();
}
}
}
componentWillUnmount() {
EventBus.off('refresh');
}
/**
* Gets the icon for the search engine dropdown.
* @param {string} name - The name of the search engine.
* @returns A React component.
*/
getSearchDropdownicon(name) {
switch (name) {
case 'Google':
return <BsGoogle />;
case 'DuckDuckGo':
return <SiDuckduckgo />;
case 'Bing':
return <SiMicrosoftbing />;
case 'Yahoo':
case 'Yahoo! JAPAN':
return <SiYahoo />;
case 'Яндекс':
return <FaYandex />;
case '百度':
return <SiBaidu />;
default:
return <MdScreenSearchDesktop />;
}
}
render() {
const customText = variables
.getMessage('modals.main.settings.sections.search.custom')
.split(' ')[0];
return (
<div className="searchComponents">
<div className="searchMain">
<div className={this.state.classList}>
{localStorage.getItem('searchDropdown') === 'true' ? (
<Tooltip
title={variables.getMessage('modals.main.settings.sections.search.search_engine')}
>
<button
aria-label="Search Engine"
onClick={() => this.setState({ searchDropdown: !this.state.searchDropdown })}
>
{this.getSearchDropdownicon(this.state.currentSearch)}
</button>
</Tooltip>
) : (
''
)}
<Tooltip
title={variables.getMessage('modals.main.settings.sections.search.voice_search')}
>
{this.state.microphone}
</Tooltip>
</div>
<form onSubmit={this.searchButton} className="searchBar">
<div className={this.state.classList}>
<Tooltip title={variables.getMessage('widgets.search')}>
<button onClick={this.searchButton} aria-label="Search">
<MdSearch />
</button>
</Tooltip>
</div>
<AutocompleteInput
placeholder={variables.getMessage('widgets.search')}
id="searchtext"
suggestions={this.state.suggestions}
onChange={(e) => this.getSuggestions(e)}
onClick={this.searchButton}
/>
</form>
</div>
<div>
{localStorage.getItem('searchDropdown') === 'true' &&
this.state.searchDropdown === true && (
<div className="searchDropdown">
{searchEngines.map(({ name }, key) => {
return (
<span
className={
'searchDropdownList' +
(this.state.currentSearch === name ? ' searchDropdownListActive' : '')
}
onClick={() => this.setSearch(name)}
key={key}
>
{name}
</span>
);
})}
<span
className={
'searchDropdownList' +
(this.state.currentSearch === customText ? ' searchDropdownListActive' : '')
}
onClick={() => this.setSearch(customText, 'custom')}
>
{customText}
</span>
</div>
)}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,93 @@
@import 'scss/variables';
.searchBar {
display: flex;
border-radius: 12px;
justify-content: flex-start;
flex-direction: row;
input[type='text'] {
@extend %basic;
outline: none;
border: none;
font-size: 1.2rem;
padding: 10px 0 10px 20px;
&::placeholder {
@include themed {
color: t($color);
}
}
}
}
.searchIcons {
display: flex;
gap: 10px;
margin-top: 1px;
margin-right: 10px;
.tooltip {
max-height: 44px;
}
}
.searchComponents {
position: absolute;
top: 1rem;
left: 1rem;
display: flex;
flex-flow: column;
button {
@include basicIconButton(12px, 1.2rem, ui);
}
}
.searchDropdown {
@extend %basic;
margin-top: 5px;
display: flex;
flex-flow: column;
align-content: flex-start;
text-align: left;
font-size: 0.6em;
width: 200px;
transition: 0.5s;
span {
padding: 0.5rem;
cursor: pointer;
border-radius: 12px;
&:hover {
@include themed {
background: t($btn-backgroundHover);
}
}
}
}
.searchDropdownListActive {
@include themed {
background: t($btn-backgroundHover);
}
}
.searchMain {
display: flex;
flex-flow: row;
animation: fadeIn 2s;
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,60 @@
[
{
"name": "DuckDuckGo",
"settingsName": "duckduckgo",
"url": "https://duckduckgo.com"
},
{
"name": "Google",
"settingsName": "google",
"url": "https://google.com/search"
},
{
"name": "Bing",
"settingsName": "bing",
"url": "https://bing.com/search"
},
{
"name": "Ecosia",
"settingsName": "ecosia",
"url": "https://ecosia.org/search"
},
{
"name": "Ask",
"settingsName": "ask",
"url": "https://ask.com/web"
},
{
"name": "Yahoo",
"settingsName": "yahoo",
"url": "https://search.yahoo.com/search"
},
{
"name": "Qwant",
"settingsName": "qwant",
"url": "https://www.qwant.com/"
},
{
"name": "Startpage",
"settingsName": "startpage",
"url": "https://www.startpage.com/sp/search"
},
{
"name": "Yahoo! JAPAN",
"settingsName": "yahoojp",
"url": "https://search.yahoo.co.jp/search",
"query": "p"
},
{
"name": "Яндекс",
"settingsName": "yandex",
"url": "https://yandex.ru/search",
"query": "text"
},
{
"name": "百度",
"settingsName": "baidu",
"url": "https://www.baidu.com/s",
"query": "wd"
}
]

View File

@@ -0,0 +1,201 @@
import { PureComponent, Suspense, lazy } from 'react';
import { convertTimezone } from 'modules/helpers/date';
import EventBus from 'modules/helpers/eventbus';
import './clock.scss';
const Analog = lazy(() => import('react-clock'));
export default class Clock extends PureComponent {
constructor() {
super();
this.timer = undefined;
this.state = {
time: '',
finalHour: '',
finalMinute: '',
finalSeconds: '',
ampm: '',
nowGlobal: new Date(),
minuteColour: localStorage.getItem('minuteColour'),
hourColour: localStorage.getItem('hourColour'),
};
}
startTime(
time = localStorage.getItem('seconds') === 'true' ||
localStorage.getItem('timeType') === 'analogue'
? 1000 - (Date.now() % 1000)
: 60000 - (Date.now() % 60000),
) {
this.timer = setTimeout(() => {
let now = new Date();
const timezone = localStorage.getItem('timezone');
if (timezone && timezone !== 'auto') {
now = convertTimezone(now, timezone);
}
switch (localStorage.getItem('timeType')) {
case 'percentageComplete':
this.setState({
time: (now.getHours() / 24).toFixed(2).replace('0.', '') + '%',
ampm: '',
});
break;
case 'analogue':
// load analog clock css
import('react-clock/dist/Clock.css');
this.setState({
time: now,
});
break;
default:
// Default clock
let time,
sec = '';
const zero = localStorage.getItem('zero');
if (localStorage.getItem('seconds') === 'true') {
sec = `:${('00' + now.getSeconds()).slice(-2)}`;
this.setState({ finalSeconds: `:${('00' + now.getSeconds()).slice(-2)}` });
}
if (localStorage.getItem('timeformat') === 'twentyfourhour') {
if (zero === 'false') {
time = `${now.getHours()}:${('00' + now.getMinutes()).slice(-2)}${sec}`;
this.setState({
finalHour: `${now.getHours()}`,
finalMinute: `${('00' + now.getMinutes()).slice(-2)}`,
});
} else {
time = `${('00' + now.getHours()).slice(-2)}:${('00' + now.getMinutes()).slice(
-2,
)}${sec}`;
this.setState({
finalHour: `${('00' + now.getHours()).slice(-2)}`,
finalMinute: `${('00' + now.getMinutes()).slice(-2)}`,
});
}
this.setState({
time,
ampm: '',
});
} else {
// 12 hour
let hours = now.getHours();
if (hours > 12) {
hours -= 12;
} else if (hours === 0) {
hours = 12;
}
if (zero === 'false') {
time = `${hours}:${('00' + now.getMinutes()).slice(-2)}${sec}`;
this.setState({
finalHour: `${hours}`,
finalMinute: `${('00' + now.getMinutes()).slice(-2)}`,
});
} else {
time = `${('00' + hours).slice(-2)}:${('00' + now.getMinutes()).slice(-2)}${sec}`;
this.setState({
finalHour: `${('00' + hours).slice(-2)}`,
finalMinute: `${('00' + now.getMinutes()).slice(-2)}`,
});
}
this.setState({
time,
ampm: now.getHours() > 11 ? 'PM' : 'AM',
});
}
break;
}
this.startTime();
}, time);
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'clock' || data === 'timezone') {
const element = document.querySelector('.clock-container');
if (localStorage.getItem('time') === 'false') {
return (element.style.display = 'none');
}
this.timer = null;
this.startTime(0);
element.style.display = 'block';
element.style.fontSize = `${
4 * Number((localStorage.getItem('zoomClock') || 100) / 100)
}em`;
}
});
if (localStorage.getItem('timeType') !== 'analogue') {
document.querySelector('.clock-container').style.fontSize = `${
4 * Number((localStorage.getItem('zoomClock') || 100) / 100)
}em`;
}
this.startTime(0);
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
const enabled = (setting) => {
return localStorage.getItem(setting) === 'true';
};
if (localStorage.getItem('timeType') === 'analogue') {
return (
<Suspense fallback={<></>}>
<div className={`clockBackground ${enabled('roundClock') ? 'round' : ''}`}>
<Analog
className="analogclock clock-container"
value={this.state.time}
size={1.5 * Number(localStorage.getItem('zoomClock') || 100)}
renderMinuteMarks={enabled('minuteMarks')}
renderHourMarks={enabled('hourMarks')}
renderSecondHand={enabled('secondHand')}
renderMinuteHand={enabled('minuteHand')}
renderHourHand={enabled('hourHand')}
/>
</div>
</Suspense>
);
}
if (localStorage.getItem('timeType') === 'verticalClock') {
return (
<span className="new-clock clock-container">
{' '}
<div className="hour" style={{ color: this.state.hourColour }}>
{this.state.finalHour}
</div>{' '}
<div className="minute" style={{ color: this.state.minuteColour }}>
{this.state.finalMinute}
</div>{' '}
<div className="seconds">{this.state.finalSeconds}</div>{' '}
</span>
);
}
return (
<span className="clock clock-container">
{this.state.time}
<span className="ampm">{this.state.ampm}</span>
</span>
);
}
}

View File

@@ -0,0 +1,174 @@
import variables from 'config/variables';
import { PureComponent, createRef } from 'react';
import { nth, convertTimezone } from '../../../modules/helpers/date';
import EventBus from 'modules/helpers/eventbus';
import './date.scss';
export default class DateWidget extends PureComponent {
constructor() {
super();
this.state = {
date: '',
weekNumber: null,
};
this.date = createRef();
}
/**
* Get the week number of the year for the given date.
* @param {Date} date
*/
getWeekNumber(date) {
const dateToday = new Date(date.valueOf());
const dayNumber = (dateToday.getDay() + 6) % 7;
dateToday.setDate(dateToday.getDate() - dayNumber + 3);
const firstThursday = dateToday.valueOf();
dateToday.setMonth(0, 1);
if (dateToday.getDay() !== 4) {
dateToday.setMonth(0, 1 + ((4 - dateToday.getDay() + 7) % 7));
}
this.setState({
weekNumber: `${variables.getMessage('widgets.date.week')} ${
1 + Math.ceil((firstThursday - dateToday) / 604800000)
}`,
});
}
getDate() {
let date = new Date();
const timezone = localStorage.getItem('timezone');
if (timezone && timezone !== 'auto') {
date = convertTimezone(date, timezone);
}
if (localStorage.getItem('weeknumber') === 'true') {
this.getWeekNumber(date);
} else if (this.state.weekNumber !== null) {
this.setState({
weekNumber: null,
});
}
if (localStorage.getItem('dateType') === 'short') {
const dateDay = date.getDate();
const dateMonth = date.getMonth() + 1;
const dateYear = date.getFullYear();
const zero = localStorage.getItem('datezero') === 'true';
let day = zero ? ('00' + dateDay).slice(-2) : dateDay;
let month = zero ? ('00' + dateMonth).slice(-2) : dateMonth;
let year = dateYear;
switch (localStorage.getItem('dateFormat')) {
case 'MDY':
day = dateMonth;
month = dateDay;
break;
case 'YMD':
day = dateYear;
year = dateDay;
break;
// DMY
default:
break;
}
let format;
switch (localStorage.getItem('shortFormat')) {
case 'dots':
format = `${day}.${month}.${year}`;
break;
case 'dash':
format = `${day}-${month}-${year}`;
break;
case 'gaps':
format = `${day} - ${month} - ${year}`;
break;
case 'slashes':
format = `${day}/${month}/${year}`;
break;
default:
break;
}
this.setState({
date: format,
});
} else {
// Long date
const lang = variables.languagecode.split('_')[0];
const datenth =
localStorage.getItem('datenth') === 'true' ? nth(date.getDate()) : date.getDate();
const dateDay =
localStorage.getItem('dayofweek') === 'true'
? date.toLocaleDateString(lang, { weekday: 'long' })
: '';
const dateMonth = date.toLocaleDateString(lang, { month: 'long' });
const dateYear = date.getFullYear();
let day = dateDay + ' ' + datenth;
let month = dateMonth;
let year = dateYear;
switch (localStorage.getItem('longFormat')) {
case 'MDY':
day = dateMonth;
month = dateDay + ' ' + datenth;
break;
case 'YMD':
day = dateYear;
year = dateDay + ' ' + datenth;
break;
// DMY
default:
break;
}
this.setState({
date: `${day} ${month} ${year}`,
});
}
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'date' || data === 'timezone') {
if (localStorage.getItem('date') === 'false') {
return (this.date.current.style.display = 'none');
}
this.date.current.style.display = 'block';
this.date.current.style.fontSize = `${Number(
(localStorage.getItem('zoomDate') || 100) / 100,
)}em`;
this.getDate();
}
});
this.date.current.style.fontSize = `${Number(
(localStorage.getItem('zoomDate') || 100) / 100,
)}em`;
this.getDate();
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
return (
<span className="date" ref={this.date}>
{this.state.date}
<br />
{this.state.weekNumber}
</span>
);
}
}

View File

@@ -0,0 +1,59 @@
@import 'scss/variables';
.clock {
font-size: 4em;
margin: 0;
cursor: initial;
user-select: none;
font-weight: 600;
--shadow-shift: 0.4rem;
}
.ampm {
font-size: 0.5em;
}
.analogclock,
.react-clock__face {
margin: 0 auto;
border-radius: 100%;
/* box-shadow: inset 0 0 100px rgba(0, 0, 0, 0.3); */
border: none !important;
@include themed {
border: 1px solid t($color) !important;
}
cursor: initial;
user-select: none;
}
.react-clock__hand__body,
.react-clock__mark__body {
@include themed {
background: t($color) !important;
}
/* box-shadow: 0 0 25px rgba(0, 0, 0, 0.3); */
}
.clockBackground {
@extend %basic;
padding: 1rem;
&.round {
border-radius: 100% !important;
}
}
.new-clock {
line-height: 100%;
.seconds {
font-size: 0.2em;
line-height: 0;
}
}

View File

@@ -0,0 +1,8 @@
.date {
cursor: initial;
user-select: none;
text-transform: capitalize;
font-weight: bold;
--shadow-shift: 0.125rem;
}

View File

@@ -0,0 +1,350 @@
[
"Africa/Abidjan",
"Africa/Accra",
"Africa/Algiers",
"Africa/Bissau",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/El_Aaiun",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Khartoum",
"Africa/Lagos",
"Africa/Maputo",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Asuncion",
"America/Atikokan",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Cayenne",
"America/Chicago",
"America/Chihuahua",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Johns",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kathmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Riyadh",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Helsinki",
"Europe/Istanbul",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zaporozhye",
"Europe/Zurich",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Wake",
"Pacific/Wallis"
]

View File

@@ -0,0 +1,119 @@
import { memo, useMemo } from 'react';
import { WiHumidity, WiWindy, WiBarometer, WiCloud } from 'react-icons/wi';
import { MdDisabledVisible } from 'react-icons/md';
import WeatherIcon from './WeatherIcon';
import WindDirectionIcon from './WindDirectionIcon';
import { Tooltip } from 'components/Elements';
function Expanded({ state: { weather, icon }, weatherType, variables }) {
/**
* If the localStorage item is true and the weatherType is greater than or equal to 3, or if the
* weatherType is equal to 3, then return true.
* @param {string} setting - The localStorage item to check.
* @returns a boolean value.
*/
const enabled = useMemo(() => {
return (setting) => {
return (localStorage.getItem(setting) === 'true' && weatherType >= 3) || weatherType === '3';
};
}, [weatherType]);
return (
<div className="expanded-info">
{weatherType >= 3 && (
<span className="subtitle">
{variables.getMessage('widgets.weather.extra_information')}
</span>
)}
{enabled('cloudiness') && (
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.weather.extra_info.cloudiness',
)}
placement="left"
>
<span>
<WiCloud className="weatherIcon" />
{weather.cloudiness}%
</span>
</Tooltip>
)}
{enabled('windspeed') && (
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.weather.extra_info.wind_speed',
)}
placement="left"
>
<span>
<WiWindy className="weatherIcon" />
{weather.wind_speed}
<span className="minmax">m/s</span>{' '}
{enabled('windDirection') && (
<div style={{ fontSize: '25px', display: 'grid' }}>
<WindDirectionIcon className="weatherIcon" degrees={weather.wind_degrees} />
</div>
)}
</span>
</Tooltip>
)}
{enabled('atmosphericpressure') && (
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.weather.extra_info.atmospheric_pressure',
)}
placement="left"
>
<span>
<WiBarometer className="weatherIcon" />
{weather.pressure}
<span className="minmax">hPa</span>
</span>
</Tooltip>
)}
{enabled('weatherdescription') && (
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.weather.extra_info.weather_description',
)}
placement="left"
>
<span>
<WeatherIcon className="weatherIcon" name={icon} />
{weather.description}
</span>
</Tooltip>
)}
{enabled('visibility') && (
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.weather.extra_info.visibility',
)}
placement="left"
>
<span>
<MdDisabledVisible className="materialWeatherIcon" />
{variables.getMessage('widgets.weather.meters', {
amount: weather.visibility,
})}
</span>
</Tooltip>
)}
{enabled('humidity') && (
<Tooltip
title={variables.getMessage('modals.main.settings.sections.weather.extra_info.humidity')}
placement="left"
>
<span>
<WiHumidity className="materialWeatherIcon" />
{weather.humidity}
</span>
</Tooltip>
)}
</div>
);
}
export default memo(Expanded);

View File

@@ -0,0 +1,155 @@
import variables from 'config/variables';
import { PureComponent } from 'react';
import WeatherIcon from './WeatherIcon';
import Expanded from './Expanded';
import EventBus from 'modules/helpers/eventbus';
import './weather.scss';
const convertTemperature = (temp, format) => {
if (format === 'celsius') {
return Math.round(temp - 273.15);
} else if (format === 'fahrenheit') {
return Math.round((temp - 273.15) * 1.8 + 32);
}
return Math.round(temp);
};
export default class WeatherSettings extends PureComponent {
constructor() {
super();
this.state = {
location: localStorage.getItem('location') || 'London',
done: false,
};
}
async getWeather() {
if (this.state.done === true) {
return;
}
const zoomWeather = `${Number((localStorage.getItem('zoomWeather') || 100) / 100)}em`;
document.querySelector('.weather').style.fontSize = zoomWeather;
try {
const response = await fetch(
variables.constants.API_URL +
`/weather?city=${this.state.location}&language=${variables.languagecode}`,
);
if (!response.ok) {
this.setState({
location: variables.getMessage('modals.main.error_boundary.title'),
done: true,
});
throw new Error('Network response was not ok');
}
const data = await response.json();
if (data.status === 404) {
return this.setState({
location: variables.getMessage('widgets.weather.not_found'),
done: true,
});
}
const { temp, temp_min, temp_max, feels_like } = data.main;
const tempFormat = localStorage.getItem('tempformat');
const tempSymbols = {
celsius: '°C',
kelvin: 'K',
fahrenheit: '°F',
};
this.setState({
icon: data.weather[0].icon,
temp_text: tempSymbols[tempFormat] || 'K',
weather: {
temp: convertTemperature(temp, tempFormat),
description: data.weather[0].description,
temp_min: convertTemperature(temp_min, tempFormat),
temp_max: convertTemperature(temp_max, tempFormat),
feels_like: convertTemperature(feels_like, tempFormat),
humidity: data.main.humidity,
wind_speed: data.wind.speed,
wind_degrees: data.wind.deg,
cloudiness: data.clouds.all,
visibility: data.visibility,
pressure: data.main.pressure,
},
done: true,
});
} catch (error) {
console.error('Fetch Error: ', error);
}
document.querySelector('.top-weather svg').style.fontSize = zoomWeather;
}
componentDidMount() {
EventBus.on('refresh', (data) => {
if (data === 'weather') {
this.getWeather();
}
});
this.getWeather();
}
componentWillUnmount() {
EventBus.off('refresh');
}
render() {
if (this.state.done === false) {
return <div className="weather"></div>;
}
const weatherType = localStorage.getItem('weatherType') || 1;
if (!this.state.weather) {
return (
<div className="weather">
<span className="loc">{this.state.location}</span>
</div>
);
}
return (
<div className="weather">
<div className="top-weather">
{weatherType >= 1 && (
<div>
<WeatherIcon name={this.state.icon} />
<span>{`${this.state.weather.temp}${this.state.temp_text}`}</span>
</div>
)}
{weatherType >= 2 && (
<span className="minmax">
<span className="subtitle">{`${this.state.weather.temp_min}${this.state.temp_text}`}</span>
<span className="subtitle">{`${this.state.weather.temp_max}${this.state.temp_text}`}</span>
</span>
)}
</div>
{weatherType >= 2 && (
<div className="extra-info">
<span>
{variables.getMessage('widgets.weather.feels_like', {
amount: `${this.state.weather.feels_like}${this.state.temp_text}`,
})}
</span>
<span className="loc">{this.state.location}</span>
</div>
)}
{weatherType >= 3 && (
<Expanded weatherType={weatherType} state={this.state} variables={variables} />
)}
</div>
);
}
}

View File

@@ -0,0 +1,44 @@
import { memo } from 'react';
import {
WiDaySunny,
WiNightClear,
WiDayCloudy,
WiNightCloudy,
WiCloud,
WiCloudy,
WiDayShowers,
WiNightShowers,
WiRain,
WiThunderstorm,
WiSnow,
WiFog,
} from 'react-icons/wi';
const iconMap = {
'01d': WiDaySunny,
'01n': WiNightClear,
'02d': WiDayCloudy,
'02n': WiNightCloudy,
'03d': WiCloud,
'03n': WiCloud,
'04d': WiCloudy,
'04n': WiCloudy,
'09d': WiDayShowers,
'09n': WiNightShowers,
'10d': WiRain,
'10n': WiRain,
'11d': WiThunderstorm,
'11n': WiThunderstorm,
'13d': WiSnow,
'13n': WiSnow,
'50d': WiFog,
'50n': WiFog,
};
function WeatherIcon({ name }) {
const IconComponent = iconMap[name];
return IconComponent ? <IconComponent className="weatherIcon" /> : null;
}
export default memo(WeatherIcon);

View File

@@ -0,0 +1,57 @@
import { memo } from 'react';
import {
WiDirectionDownLeft,
WiDirectionDownRight,
WiDirectionDown,
WiDirectionLeft,
WiDirectionRight,
WiDirectionUpLeft,
WiDirectionUpRight,
WiDirectionUp,
} from 'react-icons/wi';
// degrees are imported because of a potential bug, IDK what causes it, but now it is fixed
function WindDirectionIcon({ degrees }) {
// convert the number OpenWeatherMap gives us to the closest direction or something
const directions = [
{
name: 'North',
icon: <WiDirectionUp />,
},
{
name: 'North-West',
icon: <WiDirectionUpLeft />,
},
{
name: 'West',
icon: <WiDirectionLeft />,
},
{
name: 'South-West',
icon: <WiDirectionDownLeft />,
},
{
name: 'South',
icon: <WiDirectionDown />,
},
{
name: 'South-East',
icon: <WiDirectionDownRight />,
},
{
name: 'East',
icon: <WiDirectionRight />,
},
{
name: 'North-East',
icon: <WiDirectionUpRight />,
},
];
const direction =
directions[Math.round(((degrees %= 360) < 0 ? degrees + 360 : degrees) / 45) % 8];
return direction && direction.icon;
}
export default memo(WindDirectionIcon);

View File

@@ -0,0 +1,120 @@
//noinspection CssUnknownTarget
@import 'scss/variables';
.weather {
@extend %basic;
position: absolute;
bottom: 1rem;
right: 1rem;
cursor: initial;
user-select: none;
transition: 0.8s cubic-bezier(0.075, 0.82, 0.165, 1);
width: auto;
display: grid;
place-items: center;
justify-items: start;
padding: 25px;
&:hover {
height: auto;
transition: 0.8s cubic-bezier(0.075, 0.82, 0.165, 1);
}
.expanded-info {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
.tooltip {
width: 100%;
}
.tooltipTitle {
max-height: 12px;
}
}
.extra-info {
width: 100%;
font-size: 18px;
gap: 40px;
@include themed {
color: t($weather);
}
}
.visibility {
text-transform: none !important;
}
.extra-info {
display: flex;
flex-flow: row;
justify-content: space-evenly;
span {
display: flex;
align-items: center;
}
}
}
.top-weather {
width: 100%;
display: flex;
justify-content: space-between;
gap: 25px;
div {
align-items: center;
display: flex;
svg {
font-size: 2em !important;
}
span {
font-size: 34px;
}
}
.minmax {
display: flex;
flex-flow: column;
justify-content: space-evenly;
}
}
.expanded-info {
display: none;
font-size: 18px;
text-transform: capitalize;
gap: 10px;
margin-top: 15px;
span {
display: flex;
align-items: center;
gap: 10px;
}
@include themed {
svg {
color: t($subColor);
}
.weatherIcon {
font-size: 21px !important;
display: grid;
align-items: center;
}
.materialWeatherIcon {
font-size: 18px !important;
padding: 2px;
}
}
}