Unverified Commit 65fffeac authored by Eugen Rochko's avatar Eugen Rochko Committed by GitHub

Redesign landing page (#10232)

parent 6a8dc59e
# frozen_string_literal: true
class AboutController < ApplicationController
before_action :set_body_classes
layout 'public'
before_action :set_instance_presenter, only: [:show, :more, :terms]
def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
@hide_navbar = true
end
def more
render layout: 'public'
end
def more; end
def terms
render layout: 'public'
end
def terms; end
private
......@@ -28,15 +24,4 @@ class AboutController < ApplicationController
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def set_body_classes
@body_classes = 'with-modals'
end
def initial_state_params
{
settings: { known_fediverse: Setting.show_known_fediverse_at_about_page },
token: current_session&.token,
}
end
end
# frozen_string_literal: true
class PublicTimelinesController < ApplicationController
layout 'public'
before_action :check_enabled
before_action :set_body_classes
before_action :set_instance_presenter
def show
respond_to do |format|
format.html do
@initial_state_json = ActiveModelSerializers::SerializableResource.new(
InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end
end
end
private
def check_enabled
raise ActiveRecord::RecordNotFound unless Setting.timeline_preview
end
def set_body_classes
@body_classes = 'with-modals'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end
......@@ -13,8 +13,10 @@ class TagsController < ApplicationController
respond_to do |format|
format.html do
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
@initial_state_json = ActiveModelSerializers::SerializableResource.new(
InitialStatePresenter.new(settings: {}, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end
format.rss do
......@@ -25,8 +27,7 @@ class TagsController < ApplicationController
end
format.json do
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local])
.paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = cache_collection(@statuses, Status)
render json: collection_presenter,
......@@ -55,11 +56,4 @@ class TagsController < ApplicationController
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
)
end
def initial_state_params
{
settings: {},
token: current_session&.token,
}
end
end
......@@ -56,4 +56,12 @@ module HomeHelper
'emojify'
end
end
def optional_link_to(condition, path, options = {}, &block)
if condition
link_to(path, options, &block)
else
content_tag(:div, &block)
end
end
end
......@@ -7,7 +7,6 @@ import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline';
import CommunityTimeline from '../features/standalone/community_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline';
import ModalContainer from '../features/ui/containers/modal_container';
import initialState from '../initial_state';
......@@ -26,24 +25,22 @@ export default class TimelineContainer extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
hashtag: PropTypes.string,
showPublicTimeline: PropTypes.bool.isRequired,
local: PropTypes.bool,
};
static defaultProps = {
showPublicTimeline: initialState.settings.known_fediverse,
local: !initialState.settings.known_fediverse,
};
render () {
const { locale, hashtag, showPublicTimeline } = this.props;
const { locale, hashtag, local } = this.props;
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />;
} else if (showPublicTimeline) {
timeline = <PublicTimeline />;
} else {
timeline = <CommunityTimeline />;
timeline = <PublicTimeline local={local} />;
}
return (
......@@ -51,6 +48,7 @@ export default class TimelineContainer extends React.PureComponent {
<Provider store={store}>
<Fragment>
{timeline}
{ReactDOM.createPortal(
<ModalContainer />,
document.getElementById('modal-container'),
......
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../../ui/containers/status_list_container';
import { expandCommunityTimeline } from '../../../actions/timelines';
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
import { defineMessages, injectIntl } from 'react-intl';
import { connectCommunityStream } from '../../../actions/streaming';
const messages = defineMessages({
title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
});
export default @connect()
@injectIntl
class CommunityTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(expandCommunityTimeline());
this.disconnect = dispatch(connectCommunityStream());
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
handleLoadMore = maxId => {
this.props.dispatch(expandCommunityTimeline({ maxId }));
}
render () {
const { intl } = this.props;
return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='users'
title={intl.formatMessage(messages.title)}
onClick={this.handleHeaderClick}
/>
<StatusListContainer
timelineId='community'
onLoadMore={this.handleLoadMore}
scrollKey='standalone_public_timeline'
trackScroll={false}
/>
</Column>
);
}
}
......@@ -2,13 +2,13 @@ import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { expandHashtagTimeline } from '../../../actions/timelines';
import { connectHashtagStream } from '../../../actions/streaming';
import { expandHashtagTimeline } from 'mastodon/actions/timelines';
import { connectHashtagStream } from 'mastodon/actions/streaming';
import Masonry from 'react-masonry-infinite';
import { List as ImmutableList } from 'immutable';
import DetailedStatusContainer from '../../status/containers/detailed_status_container';
import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
import { debounce } from 'lodash';
import LoadingIndicator from '../../../components/loading_indicator';
import LoadingIndicator from 'mastodon/components/loading_indicator';
const mapStateToProps = (state, { hashtag }) => ({
statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
......
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../../ui/containers/status_list_container';
import { expandPublicTimeline } from '../../../actions/timelines';
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
import { defineMessages, injectIntl } from 'react-intl';
import { connectPublicStream } from '../../../actions/streaming';
const messages = defineMessages({
title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
});
export default @connect()
@injectIntl
import ImmutablePropTypes from 'react-immutable-proptypes';
import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
import Masonry from 'react-masonry-infinite';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
import { debounce } from 'lodash';
import LoadingIndicator from 'mastodon/components/loading_indicator';
const mapStateToProps = (state, { local }) => {
const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap());
return {
statusIds: timeline.get('items', ImmutableList()),
isLoading: timeline.get('isLoading', false),
hasMore: timeline.get('hasMore', false),
};
};
export default @connect(mapStateToProps)
class PublicTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool.isRequired,
hasMore: PropTypes.bool.isRequired,
local: PropTypes.bool,
};
handleHeaderClick = () => {
this.column.scrollTop();
componentDidMount () {
this._connect();
}
setRef = c => {
this.column = c;
componentDidUpdate (prevProps) {
if (prevProps.local !== this.props.local) {
this._disconnect();
this._connect();
}
}
componentDidMount () {
const { dispatch } = this.props;
componentWillUnmount () {
this._disconnect();
}
dispatch(expandPublicTimeline());
this.disconnect = dispatch(connectPublicStream());
_connect () {
const { dispatch, local } = this.props;
dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
this.disconnect = dispatch(local ? connectCommunityStream() : connectPublicStream());
}
componentWillUnmount () {
_disconnect () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
......@@ -44,27 +61,48 @@ class PublicTimeline extends React.PureComponent {
}
handleLoadMore = maxId => {
this.props.dispatch(expandPublicTimeline({ maxId }));
const { dispatch, local } = this.props;
dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId }));
}
setRef = c => {
this.masonry = c;
}
handleHeightChange = debounce(() => {
if (!this.masonry) {
return;
}
this.masonry.forcePack();
}, 50)
render () {
const { intl } = this.props;
const { statusIds, hasMore, isLoading } = this.props;
const sizes = [
{ columns: 1, gutter: 0 },
{ mq: '415px', columns: 1, gutter: 10 },
{ mq: '640px', columns: 2, gutter: 10 },
{ mq: '960px', columns: 3, gutter: 10 },
{ mq: '1255px', columns: 3, gutter: 10 },
];
const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='globe'
title={intl.formatMessage(messages.title)}
onClick={this.handleHeaderClick}
/>
<StatusListContainer
timelineId='public'
onLoadMore={this.handleLoadMore}
scrollKey='standalone_public_timeline'
trackScroll={false}
/>
</Column>
<Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
{statusIds.map(statusId => (
<div className='statuses-grid__item' key={statusId}>
<DetailedStatusContainer
id={statusId}
compact
measureHeight
onHeightChange={this.handleHeightChange}
/>
</div>
)).toArray()}
</Masonry>
);
}
......
......@@ -23,7 +23,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
......
This diff is collapsed.
......@@ -68,6 +68,17 @@ code {
top: 2px;
left: 0;
}
label a {
color: $highlight-text-color;
text-decoration: underline;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
}
}
}
......@@ -305,7 +316,7 @@ code {
box-shadow: none;
}
&:focus:invalid {
&:focus:invalid:not(:placeholder-shown) {
border-color: lighten($error-red, 12%);
}
......@@ -346,6 +357,10 @@ code {
}
}
.input.disabled {
opacity: 0.5;
}
.actions {
margin-top: 30px;
display: flex;
......@@ -392,6 +407,10 @@ code {
background-color: darken($ui-highlight-color, 5%);
}
&:disabled:hover {
background-color: $ui-primary-color;
}
&.negative {
background: $error-value-color;
......
......@@ -295,6 +295,11 @@
cursor: default;
}
&.disabled > div {
opacity: 0.5;
cursor: default;
}
h4 {
flex: 1 1 auto;
font-size: 18px;
......
......@@ -21,6 +21,10 @@ class InstancePresenter
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
end
def active_user_count
Rails.cache.fetch('active_user_count') { Redis.current.pfcount(*(0..3).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) }
end
def status_count
Rails.cache.fetch('local_status_count') { Account.local.joins(:account_stat).sum('account_stats.statuses_count') }.to_i
end
......@@ -29,6 +33,10 @@ class InstancePresenter
Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) }
end
def sample_accounts
Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.local.searchable.joins(:account_stat).popular.limit(3) }
end
def version_number
Mastodon::Version
end
......
.features-list
.features-list__row
.text
%h6= t 'about.features.real_conversation_title'
= t 'about.features.real_conversation_body'
.visual
= fa_icon 'fw comments'
.features-list__row
.text
%h6= t 'about.features.not_a_product_title'
= t 'about.features.not_a_product_body'
.visual
= fa_icon 'fw users'
.features-list__row
.text
%h6= t 'about.features.within_reach_title'
= t 'about.features.within_reach_body'
.visual
= fa_icon 'fw mobile'
.features-list__row
.text
%h6= t 'about.features.humane_approach_title'
= t 'about.features.humane_approach_body'
.visual
= fa_icon 'fw leaf'
- if @instance_presenter.open_registrations
= render 'registration'
- else
= link_to t('auth.register_elsewhere'), 'https://joinmastodon.org/#getting-started', class: 'button button-primary'
.closed-registrations-message
- if @instance_presenter.closed_registrations_message.blank?
%p= t('about.closed_registrations')
- else
= @instance_presenter.closed_registrations_message.html_safe
.separator-or
%span= t('auth.or')
= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn'
.container-alt.links
.brand
= link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
%ul.nav
%li
- if user_signed_in?
= link_to t('settings.back'), root_url, class: 'webapp-btn'
- else
= link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
%li= link_to t('about.about_this'), about_more_path
%li
= link_to 'https://joinmastodon.org/#getting-started' do
= "#{t('about.other_instances')}"
%i.fa.fa-external-link{ style: 'padding-left: 5px;' }
= simple_form_for(new_user, url: user_session_path) do |f|
.fields-group
- if use_seamless_external_login?
= f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
- else
= f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false
.actions
= f.button :button, t('auth.login'), type: :submit, class: 'button button-primary'
%p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path
= simple_form_for(new_user, url: user_registration_path) do |f|
= f.simple_fields_for :account do |account_fields|
= account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false
%p.lead= t('about.federation_hint_html', instance: content_tag(:strong, site_hostname))
= f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false
= f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false
.fields-group
= f.simple_fields_for :account do |account_fields|
= account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false, disabled: !Setting.open_registrations
.actions
= f.button :button, t('auth.register'), type: :submit, class: 'button button-primary'
= f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: !Setting.open_registrations
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false, disabled: !Setting.open_registrations
= f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: !Setting.open_registrations
.fields-group
= f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), disabled: !Setting.open_registrations
%p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)
.actions
= f.button :button, Setting.open_registrations ? t('auth.register') : t('auth.registration_closed', instance: site_hostname), type: :submit, class: 'button button-primary', disabled: !Setting.open_registrations
......@@ -3,144 +3,76 @@
- content_for :header_tags do
%link{ rel: 'canonical', href: about_url }/
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
= javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
= render partial: 'shared/og'
.landing-page.alternative
.container
.grid
.column-0
.brand
= link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
- if Setting.timeline_preview
.column-1
.landing-page__forms
.brand
= link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
= render 'forms'
- else
.column-1.non-preview
.landing-page__forms
.brand
= link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
= render 'forms'
- if Setting.timeline_preview
.column-2
.landing-page__hero
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
.landing-page__information
.landing-page__short-description
.row
.landing-page__logo
= image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
%h1
= @instance_presenter.site_title
%small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
.landing-page__call-to-action{ dir: 'ltr' }
.row
.row__information-board
.information-board__section
%span= t 'about.user_count_before'
%strong= number_with_delimiter @instance_presenter.user_count
%span= t 'about.user_count_after', count: @instance_presenter.user_count
.information-board__section
%span= t 'about.status_count_before'
%strong= number_with_delimiter @instance_presenter.status_count
%span= t 'about.status_count_after', count: @instance_presenter.status_count
.row__mascot
.landing-page__mascot
= image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''