Merge branch 'develop'
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-20.04
|
||||
container: ghcr.io/mopidy/ci:latest
|
||||
container: ghcr.io/mopidy/ci:7
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@ -32,13 +32,11 @@ RUN apt update \
|
||||
WORKDIR /usr/src/gst-plugins-rs
|
||||
|
||||
# Clone source of gst-plugins-rs to workdir
|
||||
ARG GST_PLUGINS_RS_TAG=main
|
||||
ARG GST_PLUGINS_RS_TAG=0.10.5
|
||||
RUN git clone -c advice.detachedHead=false \
|
||||
--single-branch --depth 1 \
|
||||
--branch ${GST_PLUGINS_RS_TAG} \
|
||||
https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs.git ./
|
||||
# EXPERIMENTAL: For gstreamer-spotify set upgraded version number of dependency librespot to 0.4.2
|
||||
RUN sed -i 's/librespot = { version = "0.4", default-features = false }/librespot = { version = "0.4.2", default-features = false }/g' audio/spotify/Cargo.toml
|
||||
|
||||
# Build GStreamer plugins written in Rust (optional with --no-default-features)
|
||||
ENV DEST_DIR /target/gst-plugins-rs
|
||||
@ -99,9 +97,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - && \
|
||||
|
||||
# Install mopidy and (optional) DLNA-server dleyna from apt.mopidy.com
|
||||
# see https://docs.mopidy.com/en/latest/installation/debian/
|
||||
RUN mkdir -p /usr/local/share/keyrings \
|
||||
&& wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \
|
||||
&& wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list \
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& wget -q -O /etc/apt/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \
|
||||
&& wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/bullseye.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y \
|
||||
mopidy \
|
||||
@ -133,7 +131,7 @@ RUN git clone --depth 1 --single-branch -b ${IRIS_VERSION} https://github.com/ja
|
||||
|
||||
# Install mopidy-spotify-gstspotify (Hack, not released yet!)
|
||||
# (https://github.com/kingosticks/mopidy-spotify/tree/gstspotifysrc-hack)
|
||||
RUN git clone --depth 1 -b gstspotifysrc-hack https://github.com/kingosticks/mopidy-spotify.git mopidy-spotify \
|
||||
RUN git clone --depth 1 https://github.com/mopidy/mopidy-spotify.git mopidy-spotify \
|
||||
&& cd mopidy-spotify \
|
||||
&& python3 setup.py install \
|
||||
&& cd .. \
|
||||
|
||||
81
Dockerfile.alpine
Normal file
81
Dockerfile.alpine
Normal file
@ -0,0 +1,81 @@
|
||||
# Use Alpine edge for now as some required dependencies are only in the testing repository
|
||||
FROM alpine:edge
|
||||
|
||||
# Switch to the root user while we do our changes
|
||||
USER root
|
||||
WORKDIR /
|
||||
|
||||
# Install GStreamer and other required Debian packages
|
||||
RUN echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories \
|
||||
&& apk update \
|
||||
&& apk add \
|
||||
dumb-init \
|
||||
shadow \
|
||||
sudo \
|
||||
git \
|
||||
py3-pip \
|
||||
mopidy \
|
||||
py3-mopidy-spotify
|
||||
|
||||
# Install Node, to build Iris JS application
|
||||
RUN apk add nodejs npm
|
||||
|
||||
# Upgrade Python package manager pip
|
||||
# https://pypi.org/project/pip/
|
||||
RUN python3 -m pip install --upgrade pip
|
||||
|
||||
# Clone Iris from the repository and install in development mode.
|
||||
# This allows a binding at "/iris" to map to your local folder for development, rather than
|
||||
# installing using pip.
|
||||
# Note: ADD helps prevent RUN caching issues. When HEAD changes in repo, our cache will be invalidated!
|
||||
ADD https://api.github.com/repos/jaedb/Iris/git/refs/heads/master version.json
|
||||
ENV IRIS_VERSION=develop
|
||||
RUN git clone --depth 1 --single-branch -b ${IRIS_VERSION} https://github.com/jaedb/Iris.git /iris \
|
||||
&& cd /iris \
|
||||
&& npm install \
|
||||
&& npm run prod \
|
||||
&& python3 setup.py develop \
|
||||
&& mkdir -p /var/lib/mopidy/.config \
|
||||
&& ln -s /config /var/lib/mopidy/.config/mopidy \
|
||||
# Allow mopidy user to run system commands (restart, local scan, etc)
|
||||
&& echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers \
|
||||
# Enable container mode (disable restart option, etc.)
|
||||
&& echo "1" >> /IS_CONTAINER \
|
||||
# Copy Version file
|
||||
&& cp /iris/VERSION /
|
||||
|
||||
# Install additional mopidy extensions and Python dependencies via pip
|
||||
COPY docker/requirements.txt .
|
||||
RUN python3 -m pip install -r requirements.txt
|
||||
|
||||
# Cleanup
|
||||
RUN rm -rf /root/.cache \
|
||||
&& rm -rf /iris/node_modules
|
||||
|
||||
# Start helper script.
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Copy Default configuration for mopidy
|
||||
COPY docker/mopidy/mopidy.example.conf /config/mopidy.conf
|
||||
|
||||
# Copy the pulse-client configuratrion
|
||||
COPY docker/mopidy/pulse-client.conf /etc/pulse/client.conf
|
||||
|
||||
# Allows any user to run mopidy, but runs by default as a randomly generated UID/GID.
|
||||
# RUN useradd -ms /bin/bash mopidy
|
||||
ENV HOME=/var/lib/mopidy
|
||||
RUN set -ex \
|
||||
&& usermod -G audio,wheel mopidy \
|
||||
&& mkdir /var/lib/mopidy/local \
|
||||
&& chown mopidy:audio -R $HOME /entrypoint.sh /iris \
|
||||
&& chmod go+rwx -R $HOME /entrypoint.sh /iris
|
||||
|
||||
# Runs as mopidy user by default.
|
||||
USER mopidy:audio
|
||||
|
||||
VOLUME ["/var/lib/mopidy/local"]
|
||||
|
||||
EXPOSE 6600 6680 1704 1705 5555/udp
|
||||
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "/entrypoint.sh"]
|
||||
CMD ["mopidy"]
|
||||
@ -1 +1 @@
|
||||
3.66.1
|
||||
3.67.0
|
||||
|
||||
@ -7,6 +7,7 @@ include mopidy_iris/ext.conf
|
||||
include mopidy_iris/system.sh
|
||||
include IRIS_VERSION
|
||||
include Dockerfile
|
||||
include Dockerfile.alpine
|
||||
include pyproject.toml
|
||||
include screenshot.jpg
|
||||
include tox.ini
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
|
||||
if [ -z "$PULSE_COOKIE_DATA" ]
|
||||
then
|
||||
echo -ne $(echo $PULSE_COOKIE_DATA | sed -e 's/../\\x&/g') >$HOME/pulse.cookie
|
||||
export PULSE_COOKIE=$HOME/pulse.cookie
|
||||
printf '%s' "$(echo "$PULSE_COOKIE_DATA" | sed -e 's/../\\x&/g')" >"$HOME"/pulse.cookie
|
||||
export PULSE_COOKIE="$HOME"/pulse.cookie
|
||||
fi
|
||||
|
||||
if [ ${PIP_PACKAGES:+x} ]; then
|
||||
echo "-- INSTALLING PIP PACKAGES $PIP_PACKAGES --"
|
||||
python3 -m pip install --no-cache $PIP_PACKAGES
|
||||
python3 -m pip install --no-cache --upgrade "$PIP_PACKAGES"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"tox": "docker exec -it -u root iris_mopidy_1 bash -c \"cd /iris && tox\"",
|
||||
"tox": "docker exec -it -u root mopidy bash -c \"cd /iris && tox\"",
|
||||
"start": "NODE_ENV=development WEBPACK_DEV_SERVER=1 webpack-dev-server",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
|
||||
@ -116,7 +116,7 @@
|
||||
</body>
|
||||
<script type="text/javascript">
|
||||
if ('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('service-worker.js')
|
||||
navigator.serviceWorker.register('<%= baseHref %>service-worker.js')
|
||||
.then(function(registration){
|
||||
console.log('Service worker registered');
|
||||
}).catch(function(error){
|
||||
|
||||
@ -53,7 +53,7 @@ const Content = () => (
|
||||
<Route path="queue/history" element={<QueueHistory />}/>
|
||||
<Route path="settings/debug" element={<Debug />} />
|
||||
<Route path="settings/*" element={<Settings />} />
|
||||
<Route path="search/" element={<Search />} />
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="search/:type/:term" element={<Search />} />
|
||||
<Route path="artist/:uri/*" element={<Artist />} />
|
||||
<Route path="album/:uri/" element={<Album />} />
|
||||
|
||||
@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux';
|
||||
import { compact } from 'lodash';
|
||||
import { I18n } from '../../locale';
|
||||
import Link from '../Link';
|
||||
import Loader from '../Loader';
|
||||
import Icon from '../Icon';
|
||||
import { encodeUri } from '../../util/format';
|
||||
import {
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { connect, useSelector, useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { find, groupBy, map } from 'lodash';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { find, groupBy, map, isEmpty } from 'lodash';
|
||||
import VolumeControl from './VolumeControl';
|
||||
import MuteControl from './MuteControl';
|
||||
import Icon from '../Icon';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import LinksSentence from '../LinksSentence';
|
||||
import DropdownField from './DropdownField';
|
||||
import * as coreActions from '../../services/core/actions';
|
||||
import * as mopidyActions from '../../services/mopidy/actions';
|
||||
import * as pusherActions from '../../services/pusher/actions';
|
||||
import * as snapcastActions from '../../services/snapcast/actions';
|
||||
import { sortItems, indexToArray } from '../../util/arrays';
|
||||
@ -166,18 +163,10 @@ const Group = ({
|
||||
};
|
||||
|
||||
const Outputs = () => {
|
||||
const snapcastEnabled = useSelector((state) => state.snapcast.enabled);
|
||||
const allGroups = indexToArray(useSelector((state) => state.snapcast.groups || {}));
|
||||
const allStreams = useSelector((state) => state.snapcast.streams || {});
|
||||
const allServers = indexToArray(useSelector((state) => state.mopidy.servers || {}));
|
||||
const groupsByStream = groupBy(allGroups, 'stream_id');
|
||||
if (!snapcastEnabled) {
|
||||
return (
|
||||
<p className="no-results">
|
||||
<I18n path="playback_controls.snapcast_not_enabled" />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@ -199,12 +188,10 @@ const Outputs = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const Commands = () => {
|
||||
const Commands = ({ commands }) => {
|
||||
const dispatch = useDispatch();
|
||||
const commandsObj = useSelector((state) => state.pusher.commands || {});
|
||||
if (!commandsObj) return null;
|
||||
|
||||
let items = indexToArray(commandsObj);
|
||||
let items = indexToArray(commands);
|
||||
if (items.length <= 0) return null;
|
||||
|
||||
items = sortItems(items, 'sort_order');
|
||||
@ -230,6 +217,8 @@ const Commands = () => {
|
||||
};
|
||||
|
||||
const OutputControl = ({ force_expanded }) => {
|
||||
const snapcastEnabled = useSelector((state) => state.snapcast.enabled);
|
||||
const commands = useSelector((state) => state.pusher.commands);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -238,9 +227,9 @@ const OutputControl = ({ force_expanded }) => {
|
||||
}
|
||||
}, [force_expanded]);
|
||||
|
||||
if (!snapcastEnabled && isEmpty(commands)) return null;
|
||||
|
||||
if (expanded) {
|
||||
const outputs = <Outputs />;
|
||||
const commands = <Commands />;
|
||||
return (
|
||||
<span className="output-control">
|
||||
{!force_expanded && <div className="click-outside" onClick={() => setExpanded(false)} />}
|
||||
@ -251,8 +240,8 @@ const OutputControl = ({ force_expanded }) => {
|
||||
<Icon name="speaker" />
|
||||
</button>
|
||||
<div className="output-control__inner">
|
||||
{commands}
|
||||
{outputs}
|
||||
{!isEmpty(commands) && <Commands commands={commands} />}
|
||||
{snapcastEnabled && <Outputs />}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { sortItems } from '../util/arrays';
|
||||
import URILink from './URILink';
|
||||
import Icon from './Icon';
|
||||
@ -9,40 +10,25 @@ import { I18n } from '../locale';
|
||||
import Button from './Button';
|
||||
import { makeSearchResultsSelector, getSortSelector } from '../util/selectors';
|
||||
|
||||
const SORT_KEY = 'search_results';
|
||||
|
||||
const SearchResults = ({
|
||||
type,
|
||||
query,
|
||||
sortField,
|
||||
sortReverse: sortReverseProp,
|
||||
uri_schemes_priority,
|
||||
all,
|
||||
results: rawResults,
|
||||
}) => {
|
||||
const encodedTerm = encodeURIComponent(query.term);
|
||||
let results = rawResults;
|
||||
let sortReverse = sortReverseProp;
|
||||
|
||||
if (!results) return null;
|
||||
|
||||
let sort_map = null;
|
||||
switch (sortField) {
|
||||
case 'uri':
|
||||
sort_map = uri_schemes_priority;
|
||||
break;
|
||||
case 'followers':
|
||||
// Followers (aka popularlity works in reverse-numerical order)
|
||||
// Ie "more popular" is a bigger number
|
||||
sortReverse = !sortReverse;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const { term } = useParams();
|
||||
const [sortField, sortReverse] = useSelector(
|
||||
(state) => getSortSelector(state, SORT_KEY, 'name'),
|
||||
);
|
||||
const searchResultsSelector = makeSearchResultsSelector(term, type);
|
||||
const rawResults = useSelector(searchResultsSelector);
|
||||
const encodedTerm = encodeURIComponent(term);
|
||||
let results = [...rawResults];
|
||||
|
||||
results = sortItems(
|
||||
results,
|
||||
(type === 'tracks' && sortField === 'followers' ? 'popularity' : sortField),
|
||||
sortReverse,
|
||||
sort_map,
|
||||
);
|
||||
|
||||
const resultsCount = results.length;
|
||||
@ -50,7 +36,7 @@ const SearchResults = ({
|
||||
results = results.slice(0, 6);
|
||||
}
|
||||
|
||||
if (results.length <= 0) return null;
|
||||
if (all && !results.length) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -72,56 +58,59 @@ const SearchResults = ({
|
||||
</URILink>
|
||||
)}
|
||||
</h4>
|
||||
<section className="grid-wrapper">
|
||||
{type === 'artists' && <Grid items={results} show_source_icon mini={all} />}
|
||||
{type === 'albums' && <Grid items={results} show_source_icon mini={all} />}
|
||||
{type === 'playlists' && <Grid items={results} show_source_icon mini={all} />}
|
||||
{type === 'tracks' && (
|
||||
<TrackList
|
||||
source={{
|
||||
uri: `iris:search:${query.type}:${encodedTerm}`,
|
||||
name: 'Search results',
|
||||
type: 'search',
|
||||
}}
|
||||
tracks={results}
|
||||
show_source_icon
|
||||
/>
|
||||
)}
|
||||
{/* <LazyLoadListener enabled={this.props.artists_more && spotify_search_enabled} loadMore={loadMore} /> */}
|
||||
{results.length > 0 && (
|
||||
<section className="grid-wrapper">
|
||||
{type === 'artists' && <Grid items={results} show_source_icon mini={all} />}
|
||||
{type === 'albums' && <Grid items={results} show_source_icon mini={all} />}
|
||||
{type === 'playlists' && <Grid items={results} show_source_icon mini={all} />}
|
||||
{type === 'tracks' && (
|
||||
<TrackList
|
||||
source={{
|
||||
uri: `iris:search:${type}:${encodedTerm}`,
|
||||
name: 'Search results',
|
||||
type: 'search',
|
||||
}}
|
||||
tracks={results}
|
||||
show_source_icon
|
||||
/>
|
||||
)}
|
||||
{/* <LazyLoadListener enabled={this.props.artists_more && spotify_search_enabled} loadMore={loadMore} /> */}
|
||||
|
||||
{resultsCount > results.length && (
|
||||
<Button uri={`iris:search:${type}:${encodedTerm}`} uriType="search" unencoded>
|
||||
<I18n path={`search.${type}.more`} count={resultsCount} />
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
{resultsCount > results.length && (
|
||||
<Button uri={`iris:search:${type}:${encodedTerm}`} uriType="search" unencoded>
|
||||
<I18n path={`search.${type}.more`} count={resultsCount} />
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const {
|
||||
query: {
|
||||
term,
|
||||
},
|
||||
type,
|
||||
} = ownProps;
|
||||
const {
|
||||
ui: {
|
||||
uri_schemes_priority = [],
|
||||
},
|
||||
} = state;
|
||||
const searchResultsSelector = makeSearchResultsSelector(term, type);
|
||||
const { sortField, sortReverse } = getSortSelector(state, 'search_results');
|
||||
const AllSearchResults = () => (
|
||||
<>
|
||||
<div className="search-result-sections cf">
|
||||
<section className="search-result-sections__item">
|
||||
<div className="inner">
|
||||
<SearchResults type="artists" all />
|
||||
</div>
|
||||
</section>
|
||||
<section className="search-result-sections__item">
|
||||
<div className="inner">
|
||||
<SearchResults type="albums" all />
|
||||
</div>
|
||||
</section>
|
||||
<section className="search-result-sections__item">
|
||||
<div className="inner">
|
||||
<SearchResults type="playlists" all />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SearchResults type="tracks" all />
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
results: searchResultsSelector(state),
|
||||
uri_schemes_priority,
|
||||
sortField,
|
||||
sortReverse,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
|
||||
export {
|
||||
SearchResults,
|
||||
AllSearchResults,
|
||||
}
|
||||
@ -58,6 +58,11 @@ common:
|
||||
filter: Filtrer
|
||||
by: ' par '
|
||||
loading_library: 'Chargement de %{provider} %{type}'
|
||||
play_state:
|
||||
playing: Lecture en cours
|
||||
paused: En pause
|
||||
stopped: Arrêté
|
||||
idle: Inactif
|
||||
fields:
|
||||
start_typing: 'Commencez à taper...'
|
||||
search: 'Rechercher...'
|
||||
@ -117,7 +122,8 @@ specs:
|
||||
playlists: '%{count} listes de lecture'
|
||||
plays: '%{count} lectures'
|
||||
popularity: '%{percent}% popularité'
|
||||
added: 'Ajouté '
|
||||
added_from: 'Ajouté à partir de '
|
||||
added_by: 'Ajouté par %{by}'
|
||||
added_ago: 'Ajouté il y a %{time}'
|
||||
edited: 'Edited '
|
||||
edited_ago: 'Édité il y a %{time}'
|
||||
@ -132,6 +138,7 @@ services:
|
||||
searching: 'Recherche dans %{provider} %{type}'
|
||||
searching_providers: 'Recherche parmi %{count} Mopidy services'
|
||||
loading_albums: 'Chargement de %{count} albums locaux'
|
||||
loading_artwork: 'Chargement des illustrations'
|
||||
spotify:
|
||||
title: Spotify
|
||||
pusher:
|
||||
@ -148,6 +155,12 @@ services:
|
||||
title: LastFM
|
||||
love: Aimer
|
||||
unlove: Ne plus aimer
|
||||
youtube:
|
||||
title: YouTube
|
||||
tidal:
|
||||
title: Tidal
|
||||
jellyfin:
|
||||
title: Jellyfin
|
||||
|
||||
##
|
||||
# Components
|
||||
@ -184,6 +197,7 @@ context_menu:
|
||||
unpin: Ne plus épingler
|
||||
go_to_artist: Vers l'artiste
|
||||
go_to_user: Vers l'utilisateur
|
||||
go_to_track: Vers le morceau
|
||||
track_info: Info sur le morceau
|
||||
discover_similar: Découvrir des morceaux similaires
|
||||
start_radio: Démarrer la radio
|
||||
@ -210,6 +224,7 @@ snapcast:
|
||||
latency: Latence
|
||||
stream: Flux
|
||||
volume: Volume
|
||||
not_controllable: Non contrôlable
|
||||
encryption:
|
||||
label: Chiffrement
|
||||
sublabel: Utiliser SSL
|
||||
@ -406,7 +421,10 @@ settings:
|
||||
upgrade_available: 'Mise-à-jour disponible (%{version})'
|
||||
upgrade: 'Mettre à jour vers %{version}'
|
||||
up_to_date: À jour
|
||||
share_configuration: Partager la configuration
|
||||
shared_configuration:
|
||||
label: Partager la configuration
|
||||
share: Exporter/partager
|
||||
import: Importer depuis un serveur
|
||||
restart: Redémarrer le serveur
|
||||
reset: Réinitialiser tous les paramètres
|
||||
about:
|
||||
@ -420,6 +438,7 @@ settings:
|
||||
no_connections: Pas de connexion
|
||||
you: ' (vous)'
|
||||
servers:
|
||||
current_track: Morceau actuel
|
||||
inactive: Inactif
|
||||
connecting: Connexion en cours
|
||||
disconnected: Déconnecté
|
||||
@ -463,12 +482,9 @@ user:
|
||||
you: Vous
|
||||
discover:
|
||||
title: Découvrir
|
||||
categories:
|
||||
moods:
|
||||
title: 'Genre / Ambiance'
|
||||
category:
|
||||
title: 'Genre / Ambiance'
|
||||
category: Categorie
|
||||
featured:
|
||||
featured_playlists:
|
||||
title: Listes de lecture en vedette
|
||||
new_releases:
|
||||
title: Nouveautés
|
||||
@ -553,23 +569,46 @@ modal:
|
||||
title: Lecture en cours
|
||||
title_window: '%{name} par %{artist} (lecture en cours)'
|
||||
lyrics: Paroles
|
||||
share_configuration:
|
||||
title: Partager la configuration
|
||||
subtitle: Partager vos autorisations et vos paramètres d'interface avec un autre utilisateur connecté
|
||||
import_configuration:
|
||||
title: Importer la configuration
|
||||
configurations: Configurations à importer
|
||||
import_now: Importer maintenant
|
||||
shared_config:
|
||||
no_peers: Aucune autre connexion
|
||||
recipients: Destinataire
|
||||
configurations: Configurations
|
||||
authorization: '%{service} autorisation'
|
||||
logged_in_as: 'Connecté comme %{name}'
|
||||
snapcast_description: Détail de la connexion
|
||||
interface: Paramètres d'interface
|
||||
interface_description: Thème, tri, filtres, etc.
|
||||
import:
|
||||
title: Configuration partagée
|
||||
subtitle: 'Un autre utilisateur a partagé sa configuration avec vous. Elle contient :'
|
||||
do_you_want_to_import: Voulez-vous importer cette configuration ?
|
||||
import_now: Importer
|
||||
successful: Import réussi
|
||||
import_now: Importer maintenant
|
||||
imported: Import réussi
|
||||
share: Partager maintenant
|
||||
shared: Partage réussi
|
||||
push:
|
||||
title: Partage de configuration
|
||||
subtitle: "Partager les autorisations et les paramètres de l'interface avec un autre utilisateur connecté"
|
||||
pushed:
|
||||
title: Configuration reçue
|
||||
subtitle: Un autre utilisateur a partagé sa configuration avec vous
|
||||
server:
|
||||
title: Import de configuration
|
||||
subtitle: Importer des autorisations et autres paramètres
|
||||
label: Serveur
|
||||
description: "Sauvegarder sur le serveur pour permettre l'import par d'autres utilisateurs"
|
||||
config:
|
||||
label: Configurations
|
||||
ui:
|
||||
label: Interface utilisateur
|
||||
description: Thème, tri, filtres, etc
|
||||
spotify:
|
||||
label: Autorisation Spotify
|
||||
description: 'Connecté en tant que %{name}'
|
||||
lastfm:
|
||||
label: Autorisation LastFM
|
||||
description: 'Connecté en tant que %{name}'
|
||||
genius:
|
||||
label: Authorisation Genius
|
||||
description: 'Connecté en tant que %{name}'
|
||||
snapcast:
|
||||
label: Snapcast
|
||||
description: Détails de la connexion
|
||||
reset:
|
||||
title: Réinitialiser Iris
|
||||
subtitle: Nettoyer la configuration, le stockage ou le cache
|
||||
@ -593,6 +632,9 @@ modal:
|
||||
title: Touches multimédia
|
||||
keys:
|
||||
info: Multimédia info
|
||||
now_playing: Lecture en cours
|
||||
search: Recherche
|
||||
kiosk_mode: Mode kiosque
|
||||
exit: Annuler l'opération en cours
|
||||
play_pause: Lire/pause
|
||||
stop: Stop
|
||||
|
||||
@ -180,9 +180,10 @@ const CoreMiddleware = (function () {
|
||||
const {
|
||||
ui: {
|
||||
allow_reporting,
|
||||
uri_schemes_search_enabled = [],
|
||||
search_settings,
|
||||
},
|
||||
mopidy: {
|
||||
uri_schemes = [],
|
||||
} = {},
|
||||
} = store.getState();
|
||||
|
||||
if (allow_reporting) {
|
||||
@ -198,15 +199,13 @@ const CoreMiddleware = (function () {
|
||||
// Trigger reducer immediately; this will hose out any previous results
|
||||
next(action);
|
||||
|
||||
if (uri_schemes_search_enabled.includes('spotify:')) {
|
||||
if (!search_settings || search_settings.spotify) {
|
||||
store.dispatch(spotifyActions.getSearchResults(query));
|
||||
}
|
||||
if (uri_schemes.includes('spotify:')) {
|
||||
store.dispatch(spotifyActions.getSearchResults(query));
|
||||
}
|
||||
store.dispatch(mopidyActions.getSearchResults(
|
||||
query,
|
||||
100,
|
||||
uri_schemes_search_enabled.filter((i) => i !== 'spotify:'), // Omit Spotify; handled above
|
||||
uri_schemes.filter((i) => i !== 'spotify:'), // Omit Spotify; handled above
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
@ -848,12 +848,6 @@ const MopidyMiddleware = (function () {
|
||||
// Enable Iris providers when the backend is available
|
||||
store.dispatch(spotifyActions.set({ enabled: uri_schemes.includes('spotify:') }));
|
||||
store.dispatch(googleActions.set({ enabled: uri_schemes.includes('gmusic:') }));
|
||||
|
||||
// If we haven't customised our search schemes, add all to search
|
||||
if (store.getState().ui.uri_schemes_search_enabled === undefined) {
|
||||
store.dispatch(uiActions.set({ uri_schemes_search_enabled: uri_schemes }));
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'MOPIDY_URI_SCHEMES', uri_schemes });
|
||||
},
|
||||
);
|
||||
|
||||
@ -20,7 +20,6 @@ import {
|
||||
makeProvidersSelector,
|
||||
getSortSelector,
|
||||
} from '../../util/selectors';
|
||||
import { formatSimpleObject } from '../../util/format';
|
||||
|
||||
const SORT_KEY = 'library_artists';
|
||||
const processKeys = [
|
||||
|
||||
@ -124,7 +124,6 @@ const ShareConfig = () => {
|
||||
'grid_glow_enabled',
|
||||
'sort',
|
||||
'initial_setup_complete',
|
||||
'uri_schemes_search_enabled',
|
||||
],
|
||||
),
|
||||
snapcast: pick(snapcast, ['enabled', 'host', 'port', 'ssl']),
|
||||
|
||||
@ -1,294 +1,101 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Header from '../components/Header';
|
||||
import Icon from '../components/Icon';
|
||||
import DropdownField from '../components/Fields/DropdownField';
|
||||
import SearchForm from '../components/Fields/SearchForm';
|
||||
import SearchResults from '../components/SearchResults';
|
||||
import * as coreActions from '../services/core/actions';
|
||||
import * as uiActions from '../services/ui/actions';
|
||||
import * as mopidyActions from '../services/mopidy/actions';
|
||||
import * as spotifyActions from '../services/spotify/actions';
|
||||
import { titleCase } from '../util/helpers';
|
||||
import { withRouter } from '../util';
|
||||
import { AllSearchResults, SearchResults } from '../components/SearchResults';
|
||||
import { startSearch } from '../services/core/actions';
|
||||
import {
|
||||
setSort,
|
||||
hideContextMenu,
|
||||
setWindowTitle,
|
||||
} from '../services/ui/actions';
|
||||
import { i18n } from '../locale';
|
||||
import { getSortSelector } from '../util/selectors';
|
||||
|
||||
class Search extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { term: props.term || '' };
|
||||
}
|
||||
const SORT_KEY = 'search_results';
|
||||
|
||||
componentDidMount = () => {
|
||||
const {
|
||||
uiActions: {
|
||||
setWindowTitle,
|
||||
},
|
||||
} = this.props;
|
||||
const Search = () => {
|
||||
const { term, type = 'all' } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const lastQuery = useSelector((state) => state.core?.search_results?.query);
|
||||
const [sortField, sortReverse] = useSelector(
|
||||
(state) => getSortSelector(state, SORT_KEY, 'name'),
|
||||
);
|
||||
|
||||
setWindowTitle('Search');
|
||||
|
||||
// Auto-focus on the input field
|
||||
useEffect(() => {
|
||||
dispatch(setWindowTitle('Search'));
|
||||
$(document).find('.search-form input').focus();
|
||||
this.digestUri();
|
||||
}
|
||||
}, []);
|
||||
|
||||
componentDidUpdate = ({ term: prevTerm }) => {
|
||||
const { term: termProp } = this.props;
|
||||
if (prevTerm !== termProp) {
|
||||
this.search();
|
||||
useEffect(() => {
|
||||
if (term && type && term !== lastQuery?.term) {
|
||||
dispatch(setWindowTitle(i18n('search.title_window', { term: decodeURIComponent(term) })));
|
||||
dispatch(startSearch({ term, type }));
|
||||
}
|
||||
}, [term, type])
|
||||
|
||||
const onSubmit = (nextTerm) => {
|
||||
const encodedTerm = encodeURIComponent(nextTerm);
|
||||
navigate(`/search/${type}/${encodedTerm}`);
|
||||
}
|
||||
|
||||
onSubmit = (term) => {
|
||||
const { navigate, type } = this.props;
|
||||
const encodedTerm = encodeURIComponent(term);
|
||||
|
||||
this.setState(
|
||||
{ term },
|
||||
() => {
|
||||
navigate(`/search/${type}/${encodedTerm}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onReset = () => {
|
||||
const { navigate } = this.props;
|
||||
navigate('/search');
|
||||
}
|
||||
|
||||
onSortChange = (value) => {
|
||||
const { uiActions: { hideContextMenu } } = this.props;
|
||||
this.setSort(value);
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
onSourceChange = (value) => {
|
||||
const {
|
||||
uiActions: {
|
||||
set,
|
||||
hideContextMenu,
|
||||
},
|
||||
} = this.props;
|
||||
set({ uri_schemes_search_enabled: value });
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
onSourceClose = () => {
|
||||
this.search(true);
|
||||
};
|
||||
|
||||
digestUri = () => {
|
||||
const { term } = this.props;
|
||||
if (term) {
|
||||
this.setState({ term }, this.search);
|
||||
} else {
|
||||
this.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch = () => {
|
||||
const {
|
||||
uiActions: {
|
||||
setWindowTitle,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
setWindowTitle(i18n('search.title'));
|
||||
this.setState({ term: '' });
|
||||
}
|
||||
|
||||
search = (force = false) => {
|
||||
const {
|
||||
coreActions: {
|
||||
startSearch,
|
||||
},
|
||||
uiActions: {
|
||||
setWindowTitle,
|
||||
},
|
||||
search_results_query: {
|
||||
type: existingType,
|
||||
term: existingTerm,
|
||||
},
|
||||
type,
|
||||
} = this.props;
|
||||
const { term } = this.state;
|
||||
|
||||
setWindowTitle(i18n('search.title_window', { term: decodeURIComponent(term) }));
|
||||
|
||||
if ((type && term && (force || existingType !== type || existingTerm !== term))) {
|
||||
startSearch({ type, term });
|
||||
}
|
||||
}
|
||||
|
||||
setSort = (value) => {
|
||||
const {
|
||||
sort,
|
||||
sort_reverse,
|
||||
uiActions: {
|
||||
set,
|
||||
},
|
||||
} = this.props;
|
||||
const onReset = () => navigate('/search');
|
||||
|
||||
const onSortChange = (field) => {
|
||||
let reverse = false;
|
||||
if (sort === value) reverse = !sort_reverse;
|
||||
|
||||
const data = {
|
||||
search_results_sort_reverse: reverse,
|
||||
search_results_sort: value,
|
||||
};
|
||||
set(data);
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const { term } = this.state;
|
||||
const {
|
||||
uri_schemes,
|
||||
sort,
|
||||
sort_reverse,
|
||||
uri_schemes_search_enabled,
|
||||
uiActions,
|
||||
type,
|
||||
} = this.props;
|
||||
|
||||
const sort_options = [
|
||||
{ value: 'followers', label: i18n('common.popularity') },
|
||||
{ value: 'name', label: i18n('common.name') },
|
||||
{ value: 'artist', label: i18n('common.artist') },
|
||||
{ value: 'duration', label: i18n('common.duration') },
|
||||
];
|
||||
|
||||
const provider_options = uri_schemes.map((item) => ({
|
||||
value: item,
|
||||
label: titleCase(item.replace(':', '').replace('+', ' ')),
|
||||
}));
|
||||
|
||||
const options = (
|
||||
<>
|
||||
<DropdownField
|
||||
icon="swap_vert"
|
||||
name={i18n('common.sort')}
|
||||
value={sort}
|
||||
valueAsLabel
|
||||
options={sort_options}
|
||||
selected_icon={sort_reverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
|
||||
handleChange={this.onSortChange}
|
||||
/>
|
||||
<DropdownField
|
||||
icon="cloud"
|
||||
name={i18n('common.sources')}
|
||||
value={uri_schemes_search_enabled}
|
||||
options={provider_options}
|
||||
handleChange={this.onSourceChange}
|
||||
onClose={this.onSourceClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
let searchResults;
|
||||
|
||||
switch (type) {
|
||||
case 'artists':
|
||||
searchResults = <SearchResults type="artists" query={{ term, type: 'artists' }} />;
|
||||
break;
|
||||
case 'albums':
|
||||
searchResults = <SearchResults type="albums" query={{ term, type: 'albums' }} />;
|
||||
break;
|
||||
case 'playlists':
|
||||
searchResults = <SearchResults type="playlists" query={{ term, type: 'playlists' }} />;
|
||||
break;
|
||||
case 'tracks':
|
||||
searchResults = <SearchResults type="tracks" query={{ term, type: 'tracks' }} />
|
||||
break;
|
||||
default:
|
||||
searchResults = (
|
||||
<>
|
||||
<div className="search-result-sections cf">
|
||||
<section className="search-result-sections__item">
|
||||
<div className="inner">
|
||||
<SearchResults type="artists" query={{ term, type: 'artists' }} all />
|
||||
</div>
|
||||
</section>
|
||||
<section className="search-result-sections__item">
|
||||
<div className="inner">
|
||||
<SearchResults type="albums" query={{ term, type: 'albums' }} all />
|
||||
</div>
|
||||
</section>
|
||||
<section className="search-result-sections__item">
|
||||
<div className="inner">
|
||||
<SearchResults type="playlists" query={{ term, type: 'playlists' }} all />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SearchResults type="tracks" query={{ term, type: 'tracks' }} all />
|
||||
</>
|
||||
);
|
||||
if (field !== null && sortField === field) {
|
||||
reverse = !sortReverse;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view search-view">
|
||||
<Header options={options} uiActions={uiActions}>
|
||||
<Icon name="search" type="material" />
|
||||
</Header>
|
||||
|
||||
<SearchForm
|
||||
key={`search_form_${type}_${term}`}
|
||||
term={term}
|
||||
onSubmit={this.onSubmit}
|
||||
onReset={this.onReset}
|
||||
/>
|
||||
|
||||
<div className="content-wrapper">
|
||||
{searchResults}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
dispatch(setSort(SORT_KEY, field, reverse));
|
||||
dispatch(hideContextMenu());
|
||||
}
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'name', label: i18n('common.name') },
|
||||
{ value: 'uri', label: i18n('fields.filters.source') },
|
||||
{ value: 'followers', label: i18n('common.popularity') },
|
||||
{ value: 'artist', label: i18n('common.artist') },
|
||||
{ value: 'duration', label: i18n('common.duration') },
|
||||
];
|
||||
|
||||
const options = (
|
||||
<DropdownField
|
||||
icon="swap_vert"
|
||||
name={i18n('common.sort')}
|
||||
value={sortField}
|
||||
options={sortOptions}
|
||||
selected_icon={sortField ? (sortReverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
|
||||
handleChange={onSortChange}
|
||||
valueAsLabel
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="view search-view">
|
||||
<Header options={options}>
|
||||
<Icon name="search" type="material" />
|
||||
</Header>
|
||||
|
||||
<SearchForm
|
||||
key={`search_form_${type}_${term}`}
|
||||
term={term}
|
||||
onSubmit={onSubmit}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<div className="content-wrapper">
|
||||
{type != 'all' ? (
|
||||
<SearchResults type={type} />
|
||||
) : (
|
||||
<AllSearchResults />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const {
|
||||
params: {
|
||||
type,
|
||||
term,
|
||||
},
|
||||
navigation,
|
||||
} = ownProps;
|
||||
|
||||
const {
|
||||
mopidy: {
|
||||
uri_schemes = [],
|
||||
},
|
||||
ui: {
|
||||
uri_schemes_search_enabled = [],
|
||||
search_results_sort: sort = 'followers.total',
|
||||
search_results_sort_reverse,
|
||||
},
|
||||
core: {
|
||||
search_results: {
|
||||
query: search_results_query = {},
|
||||
} = {},
|
||||
},
|
||||
} = state;
|
||||
|
||||
return {
|
||||
type: type || 'all',
|
||||
term,
|
||||
navigation,
|
||||
uri_schemes,
|
||||
uri_schemes_search_enabled,
|
||||
sort,
|
||||
sort_reverse: !!search_results_sort_reverse,
|
||||
search_results_query,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
coreActions: bindActionCreators(coreActions, dispatch),
|
||||
uiActions: bindActionCreators(uiActions, dispatch),
|
||||
mopidyActions: bindActionCreators(mopidyActions, dispatch),
|
||||
spotifyActions: bindActionCreators(spotifyActions, dispatch),
|
||||
});
|
||||
|
||||
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Search));
|
||||
export default Search;
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
{
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"manifest_version": "3.55",
|
||||
"short_name": "Iris",
|
||||
"name": "Iris",
|
||||
@ -24,7 +26,6 @@
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"background_color": "#08d58f",
|
||||
"theme_color": "#181818",
|
||||
"display": "standalone",
|
||||
|
||||
@ -106,7 +106,8 @@
|
||||
}
|
||||
|
||||
&--loader {
|
||||
padding-top: 75%;
|
||||
text-align: center;
|
||||
margin-top: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,10 @@
|
||||
border-bottom: 0 !important;
|
||||
cursor: pointer;
|
||||
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@ -111,6 +111,7 @@
|
||||
cursor: pointer;
|
||||
color: colour('white');
|
||||
border: 0 !important;
|
||||
text-decoration: none !important;
|
||||
margin: 0 5px;
|
||||
|
||||
&:hover {
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
padding: 0 5px;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
|
||||
&__inner {
|
||||
@ -60,6 +59,8 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.menu-item__inner {
|
||||
background: lighten(colour('dark_grey'), 4%);
|
||||
|
||||
|
||||
@ -7,8 +7,6 @@
|
||||
margin-right: 25px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
@include theme('light') {
|
||||
@ -21,25 +19,7 @@
|
||||
|
||||
&--active,
|
||||
&:hover {
|
||||
border-bottom-width: 3px !important;
|
||||
border-bottom-style: solid;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: colour('white') !important;
|
||||
|
||||
@include theme('light') {
|
||||
border-color: colour('darkest_grey') !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.sub-views__option--active):hover {
|
||||
border-color: colour('soft_grey') !important;
|
||||
|
||||
@include theme('light') {
|
||||
border-color: colour('light_grey') !important;
|
||||
}
|
||||
text-decoration: underline 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -194,12 +194,8 @@ main {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.control):not(.action):not(.button) {
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: colour('mid_grey');
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
@ -262,17 +258,16 @@ h2 {
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
text-decoration: none 2px;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 2px solid colour('white');
|
||||
text-decoration: underline 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.grey-text {
|
||||
a:hover {
|
||||
color: colour('mid_grey') !important;
|
||||
border-bottom: 2px solid colour('mid_grey');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -342,7 +337,7 @@ h5 {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline 0.15em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,6 +214,10 @@ select {
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@include theme('light') {
|
||||
&--default {
|
||||
border-color: colour('darkest_grey');
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
|
||||
.biography-text {
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,18 +5,18 @@
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 90px;
|
||||
right: 270px;
|
||||
right: 150px;
|
||||
|
||||
input {
|
||||
@include feature_font();
|
||||
padding: 6px 0 4px 0;
|
||||
padding: 6px 0 0 0;
|
||||
width: 100%;
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
height: 4rem;
|
||||
height: 1em;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid colour('grey');
|
||||
border-bottom: 2px solid rgba(128,128,128,0.3);
|
||||
border-radius: 0;
|
||||
letter-spacing: -0.15rem;
|
||||
|
||||
@ -24,6 +24,11 @@
|
||||
&:not(:root:root) {
|
||||
height: 4.6rem;
|
||||
margin-top: -0.5rem;
|
||||
|
||||
@include responsive($bp_medium) {
|
||||
height: 2.6rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus,
|
||||
|
||||
Reference in New Issue
Block a user