Merge branch 'master' of https://github.com/tootsuite/mastodon
# Conflicts: # app/controllers/settings/exports_controller.rb # app/models/media_attachment.rb # app/models/status.rb # app/views/about/show.html.haml # docker_entrypoint.sh # spec/views/about/show.html.haml_spec.rb
This commit is contained in:
@ -16,6 +16,7 @@ module Admin
|
||||
show_staff_badge
|
||||
bootstrap_timeline_accounts
|
||||
thumbnail
|
||||
hero
|
||||
min_invite_role
|
||||
activity_api_enabled
|
||||
peers_api_enabled
|
||||
@ -34,6 +35,7 @@ module Admin
|
||||
|
||||
UPLOAD_SETTINGS = %w(
|
||||
thumbnail
|
||||
hero
|
||||
).freeze
|
||||
|
||||
def edit
|
||||
|
@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
|
||||
end
|
||||
|
||||
def account_ids
|
||||
@_account_ids ||= Array(params[:id]).map(&:to_i)
|
||||
Array(params[:id]).map(&:to_i)
|
||||
end
|
||||
end
|
||||
|
@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
|
||||
private
|
||||
|
||||
def media_params
|
||||
params.permit(:file, :description)
|
||||
params.permit(:file, :description, :focus)
|
||||
end
|
||||
|
||||
def file_type_error
|
||||
|
@ -36,7 +36,7 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def store_current_location
|
||||
store_location_for(:user, request.url)
|
||||
store_location_for(:user, request.url) unless request.format == :json
|
||||
end
|
||||
|
||||
def require_admin!
|
||||
|
@ -11,6 +11,15 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
prepend_before_action :set_pack
|
||||
before_action :set_instance_presenter, only: [:new]
|
||||
|
||||
def new
|
||||
Devise.omniauth_configs.each do |provider, config|
|
||||
if config.strategy.redirect_at_sign_in
|
||||
return redirect_to(omniauth_authorize_path(resource_name, provider))
|
||||
end
|
||||
end
|
||||
super
|
||||
end
|
||||
|
||||
def create
|
||||
super do |resource|
|
||||
remember_me(resource)
|
||||
|
@ -2,6 +2,16 @@
|
||||
|
||||
class Settings::ExportsController < Settings::BaseController
|
||||
def show
|
||||
@export = Export.new(current_account)
|
||||
@export = Export.new(current_account)
|
||||
@backups = current_user.backups
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :backup, :create?
|
||||
|
||||
backup = current_user.backups.create!
|
||||
BackupWorker.perform_async(backup.id)
|
||||
|
||||
redirect_to settings_export_path
|
||||
end
|
||||
end
|
||||
|
4
app/javascript/images/icon_file_download.svg
Normal file
4
app/javascript/images/icon_file_download.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
</svg>
|
After Width: | Height: | Size: 205 B |
BIN
app/javascript/images/mailer/icon_file_download.png
Normal file
BIN
app/javascript/images/mailer/icon_file_download.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 271 B |
BIN
app/javascript/images/reticle.png
Normal file
BIN
app/javascript/images/reticle.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
@ -178,11 +178,11 @@ export function uploadCompose(files) {
|
||||
};
|
||||
};
|
||||
|
||||
export function changeUploadCompose(id, description) {
|
||||
export function changeUploadCompose(id, params) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(changeUploadComposeRequest());
|
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
|
||||
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
|
@ -12,6 +12,26 @@ const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
});
|
||||
|
||||
const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => {
|
||||
const containerCenter = Math.floor(containerSize / 2);
|
||||
const focusFactor = (focusSize + 1) / 2;
|
||||
const scaledImage = Math.floor(imageSize / containerToImageRatio);
|
||||
|
||||
let focus = Math.floor(focusFactor * scaledImage);
|
||||
|
||||
if (toMinus) focus = scaledImage - focus;
|
||||
|
||||
let focusOffset = focus - containerCenter;
|
||||
|
||||
const remainder = scaledImage - focus;
|
||||
const containerRemainder = containerSize - containerCenter;
|
||||
|
||||
if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder;
|
||||
if (focusOffset < 0) focusOffset = 0;
|
||||
|
||||
return (focusOffset * -100 / containerSize) + '%';
|
||||
};
|
||||
|
||||
class Item extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
@ -24,6 +44,8 @@ class Item extends React.PureComponent {
|
||||
index: PropTypes.number.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
containerWidth: PropTypes.number,
|
||||
containerHeight: PropTypes.number,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -62,7 +84,7 @@ class Item extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size, standalone } = this.props;
|
||||
const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
@ -116,16 +138,40 @@ class Item extends React.PureComponent {
|
||||
let thumbnail = '';
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
const originalHeight = attachment.getIn(['meta', 'original', 'height']);
|
||||
|
||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||
|
||||
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
|
||||
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
|
||||
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
|
||||
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']);
|
||||
const imageStyle = {};
|
||||
|
||||
if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) {
|
||||
const widthRatio = originalWidth / (containerWidth * (width / 100));
|
||||
const heightRatio = originalHeight / (containerHeight * (height / 100));
|
||||
|
||||
let hShift = 0;
|
||||
let vShift = 0;
|
||||
|
||||
if (widthRatio > heightRatio) {
|
||||
hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX);
|
||||
} else if(widthRatio < heightRatio) {
|
||||
vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true);
|
||||
}
|
||||
|
||||
imageStyle.top = vShift;
|
||||
imageStyle.left = hShift;
|
||||
} else {
|
||||
imageStyle.height = '100%';
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<a
|
||||
@ -134,7 +180,14 @@ class Item extends React.PureComponent {
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
>
|
||||
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
|
||||
<img
|
||||
src={previewUrl}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
if (node && this.isStandaloneEligible()) {
|
||||
if (node /*&& this.isStandaloneEligible()*/) {
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
this.setState({
|
||||
width: node.offsetWidth,
|
||||
@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} containerWidth={width} containerHeight={height} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style}>
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
||||
</div>
|
||||
|
@ -1,15 +1,13 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
|
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||
});
|
||||
|
||||
@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent {
|
||||
intl: PropTypes.object.isRequired,
|
||||
onUndo: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onOpenFocalPoint: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
|
||||
this.props.onUndo(this.props.media.get('id'));
|
||||
}
|
||||
|
||||
handleFocalPointClick = () => {
|
||||
this.props.onOpenFocalPoint(this.props.media.get('id'));
|
||||
}
|
||||
|
||||
handleInputChange = e => {
|
||||
this.setState({ dirtyDescription: e.target.value });
|
||||
}
|
||||
@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
|
||||
const { intl, media } = this.props;
|
||||
const active = this.state.hovered || this.state.focused;
|
||||
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
|
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||
<div className={classNames('compose-form__upload__actions', { active })}>
|
||||
<button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
|
||||
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
|
||||
</div>
|
||||
|
||||
<div className={classNames('compose-form__upload-description', { active })}>
|
||||
<label>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Upload from '../components/upload';
|
||||
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
|
||||
},
|
||||
|
||||
onDescriptionChange: (id, description) => {
|
||||
dispatch(changeUploadCompose(id, description));
|
||||
dispatch(changeUploadCompose(id, { description }));
|
||||
},
|
||||
|
||||
onOpenFocalPoint: id => {
|
||||
dispatch(openModal('FOCAL_POINT', { id }));
|
||||
},
|
||||
|
||||
});
|
||||
|
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import ImageLoader from './image_loader';
|
||||
import classNames from 'classnames';
|
||||
import { changeUploadCompose } from '../../../actions/compose';
|
||||
import { getPointerPosition } from '../../video';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
|
||||
onSave: (x, y) => {
|
||||
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class FocalPointModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
focusX: 0,
|
||||
focusY: 0,
|
||||
dragging: false,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.updatePositionFromMedia(this.props.media);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (this.props.media.get('id') !== nextProps.media.get('id')) {
|
||||
this.updatePositionFromMedia(nextProps.media);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
}
|
||||
|
||||
handleMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseMove);
|
||||
document.addEventListener('mouseup', this.handleMouseUp);
|
||||
|
||||
this.updatePosition(e);
|
||||
this.setState({ dragging: true });
|
||||
}
|
||||
|
||||
handleMouseMove = e => {
|
||||
this.updatePosition(e);
|
||||
}
|
||||
|
||||
handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
|
||||
this.setState({ dragging: false });
|
||||
this.props.onSave(this.state.focusX, this.state.focusY);
|
||||
}
|
||||
|
||||
updatePosition = e => {
|
||||
const { x, y } = getPointerPosition(this.node, e);
|
||||
const focusX = (x - .5) * 2;
|
||||
const focusY = (y - .5) * -2;
|
||||
|
||||
this.setState({ x, y, focusX, focusY });
|
||||
}
|
||||
|
||||
updatePositionFromMedia = media => {
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
|
||||
if (focusX && focusY) {
|
||||
const x = (focusX / 2) + .5;
|
||||
const y = (focusY / -2) + .5;
|
||||
|
||||
this.setState({ x, y, focusX, focusY });
|
||||
} else {
|
||||
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
const { x, y, dragging } = this.state;
|
||||
|
||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div className={classNames('media-modal__content focal-point', { dragging })} ref={this.setRef}>
|
||||
<ImageLoader
|
||||
previewSrc={media.get('preview_url')}
|
||||
src={media.get('url')}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
|
||||
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
|
||||
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -8,6 +8,7 @@ import MediaModal from './media_modal';
|
||||
import VideoModal from './video_modal';
|
||||
import BoostModal from './boost_modal';
|
||||
import ConfirmationModal from './confirmation_modal';
|
||||
import FocalPointModal from './focal_point_modal';
|
||||
import {
|
||||
OnboardingModal,
|
||||
MuteModal,
|
||||
@ -27,6 +28,7 @@ const MODAL_COMPONENTS = {
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'LIST_EDITOR': ListEditor,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
};
|
||||
|
||||
export default class ModalRoot extends React.PureComponent {
|
||||
|
@ -30,7 +30,7 @@ const formatTime = secondsNum => {
|
||||
return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const findElementPosition = el => {
|
||||
export const findElementPosition = el => {
|
||||
let box;
|
||||
|
||||
if (el.getBoundingClientRect && el.parentNode) {
|
||||
@ -61,7 +61,7 @@ const findElementPosition = el => {
|
||||
};
|
||||
};
|
||||
|
||||
const getPointerPosition = (el, event) => {
|
||||
export const getPointerPosition = (el, event) => {
|
||||
const position = {};
|
||||
const box = findElementPosition(el);
|
||||
const boxW = el.offsetWidth;
|
||||
@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
|
||||
pageY = event.changedTouches[0].pageY;
|
||||
}
|
||||
|
||||
position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
|
||||
position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
|
||||
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
|
||||
|
||||
return position;
|
||||
|
@ -34,7 +34,7 @@ import uuid from '../uuid';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
mounted: false,
|
||||
mounted: 0,
|
||||
sensitive: false,
|
||||
spoiler: false,
|
||||
spoiler_text: '',
|
||||
@ -159,10 +159,10 @@ export default function compose(state = initialState, action) {
|
||||
case STORE_HYDRATE:
|
||||
return hydrate(state, action.state.get('compose'));
|
||||
case COMPOSE_MOUNT:
|
||||
return state.set('mounted', true);
|
||||
return state.set('mounted', state.get('mounted') + 1);
|
||||
case COMPOSE_UNMOUNT:
|
||||
return state
|
||||
.set('mounted', false)
|
||||
.set('mounted', Math.max(state.get('mounted') - 1, 0))
|
||||
.set('is_composing', false);
|
||||
case COMPOSE_SENSITIVITY_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
@ -265,7 +265,7 @@ export default function compose(state = initialState, action) {
|
||||
.set('is_submitting', false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
if (item.get('id') === action.media.id) {
|
||||
return item.set('description', action.media.description);
|
||||
return fromJS(action.media);
|
||||
}
|
||||
|
||||
return item;
|
||||
|
@ -1,3 +1,130 @@
|
||||
$maximum-width: 1235px;
|
||||
$fluid-breakpoint: $maximum-width + 20px;
|
||||
$column-breakpoint: 700px;
|
||||
$small-breakpoint: 960px;
|
||||
|
||||
.container {
|
||||
box-sizing: border-box;
|
||||
max-width: $maximum-width;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
|
||||
@media screen and (max-width: $fluid-breakpoint) {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.show-xs,
|
||||
.show-sm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-m {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-breakpoint) {
|
||||
.hide-sm {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.show-sm {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
.hide-xs {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.show-xs {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -5px;
|
||||
|
||||
@for $i from 1 through 15 {
|
||||
.column-#{$i} {
|
||||
box-sizing: border-box;
|
||||
min-height: 1px;
|
||||
flex: 0 0 percentage($i / 15);
|
||||
max-width: percentage($i / 15);
|
||||
padding: 0 5px;
|
||||
|
||||
@media screen and (max-width: $small-breakpoint) {
|
||||
&-sm {
|
||||
box-sizing: border-box;
|
||||
min-height: 1px;
|
||||
flex: 0 0 percentage($i / 15);
|
||||
max-width: percentage($i / 15);
|
||||
padding: 0 5px;
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
max-width: 100%;
|
||||
flex: 0 0 100%;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
max-width: 100%;
|
||||
flex: 0 0 100%;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.separator-or {
|
||||
position: relative;
|
||||
margin: 40px 0;
|
||||
text-align: center;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
background: $ui-base-color;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $ui-primary-color;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-page {
|
||||
p,
|
||||
li {
|
||||
@ -116,10 +243,14 @@
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: rgba($ui-base-lighter-color, .6);
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: 800px;
|
||||
@ -152,24 +283,20 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mascot-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
.brand {
|
||||
a {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.mascot {
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
left: 60px;
|
||||
z-index: 3;
|
||||
img {
|
||||
height: 32px;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,7 +304,7 @@
|
||||
line-height: 30px;
|
||||
overflow: hidden;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@ -203,21 +330,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
a {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@ -243,53 +355,6 @@
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.floats {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
transition: all 0.1s linear;
|
||||
animation-name: floating;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
animation-timing-function: ease-in-out;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.float-1 {
|
||||
width: 324px;
|
||||
height: 170px;
|
||||
right: -120px;
|
||||
bottom: 0;
|
||||
animation-duration: 3s;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 447.1875 234.375" height="170" width="324"><path fill="#{hex-color($ui-base-lighter-color)}" d="M21.69 233.366c-6.45-1.268-13.347-5.63-16.704-10.564-10.705-15.734-1.513-37.724 18.632-44.57l4.8-1.632.173-17.753c.146-14.77.515-19.063 2.2-25.55 6.736-25.944 24.46-46.032 47.766-54.137 11.913-4.143 19.558-5.366 34.178-5.47l13.828-.096V71.12c0-4.755 2.853-17.457 5.238-23.327 8.588-21.137 26.735-35.957 52.153-42.593 23.248-6.07 50.153-6.415 71.863-.923 11.14 2.82 25.686 9.957 33.857 16.615 19.335 15.756 31.82 41.05 35.183 71.275.59 5.305.672 5.435 3.11 4.926 11.833-2.474 30.4-3.132 40.065-1.42 24.388 4.32 40.568 19.076 47.214 43.058 2.16 7.8 3.953 23.894 3.59 32.237l-.24 5.498 5.156 1.317c6.392 1.633 14.55 7.098 18.003 12.062 1.435 2.062 3.305 6.597 4.156 10.078 1.428 5.84 1.43 6.8.04 12.44-1.807 7.318-5.672 13.252-10.872 16.694-8.508 5.63 3.756 5.33-211.916 5.216-108.56-.056-199.22-.464-201.47-.906z"/></svg>');
|
||||
}
|
||||
|
||||
.float-2 {
|
||||
width: 241px;
|
||||
height: 100px;
|
||||
right: 210px;
|
||||
bottom: 0;
|
||||
animation-duration: 3.5s;
|
||||
animation-delay: 0.2s;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 536.25 222.1875" height="100" width="241"><path fill="#{hex-color($ui-base-lighter-color)}" d="M42.626 221.23c-14.104-1.174-26.442-5.133-32.825-10.534-4.194-3.548-7.684-10.66-8.868-18.075-1.934-12.102.633-22.265 7.528-29.81 7.61-8.328 19.998-12.76 39.855-14.257l8.47-.638-2.08-6.223c-4.826-14.422-6.357-24.813-6.37-43.255-.012-14.923.28-18.513 2.1-25.724 2.283-9.048 8.483-23.034 13.345-30.1 14.76-21.45 43.505-38.425 70.535-41.65 30.628-3.655 64.47 12.073 89.668 41.673l5.955 6.995 2.765-4.174c1.52-2.296 5.74-6.93 9.376-10.295 18.382-17.02 43.436-20.676 73.352-10.705 12.158 4.052 21.315 9.53 29.64 17.733 12.752 12.562 18.16 25.718 18.19 44.26l.02 10.98 2.312-3.01c15.64-20.365 42.29-20.485 62.438-.28 3.644 3.653 7.558 8.593 8.697 10.976 4.895 10.24 5.932 25.688 2.486 37.046-.76 2.507-1.388 4.816-1.393 5.13-.006.316 6.845.87 15.224 1.234 53.06 2.297 76.356 12.98 81.817 37.526 3.554 15.973-3.71 28.604-19.566 34.02-4.554 1.555-17.922 1.655-234.517 1.757-126.327.06-233.497-.21-238.154-.597z"/></svg>');
|
||||
}
|
||||
|
||||
.float-3 {
|
||||
width: 267px;
|
||||
height: 140px;
|
||||
right: 110px;
|
||||
top: -30px;
|
||||
animation-duration: 4s;
|
||||
animation-delay: 0.5s;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388.125 202.5" height="140" width="267"><path fill="#{hex-color($ui-base-lighter-color)}" d="M181.37 201.458c-17.184-1.81-36.762-8.944-49.523-18.05l-5.774-4.12-8.074 2.63c-11.468 3.738-21.382 4.962-35.815 4.422-14.79-.554-24.577-2.845-36.716-8.594-15.483-7.332-28.498-19.98-35.985-34.968C2.44 128.675-.94 108.435.9 91.356c3.362-31.234 18.197-53.698 43.63-66.074 12.803-6.23 22.384-8.55 37.655-9.122 14.433-.54 24.347.684 35.814 4.42l8.073 2.633 5.635-4.01c24.81-17.656 60.007-23.332 92.914-14.985 10.11 2.565 25.498 9.62 33.102 15.178l5.068 3.704 7.632-2.564c10.89-3.66 21.086-4.916 35.516-4.376 45.816 1.716 76.422 30.03 81.285 75.196 1.84 17.08-1.54 37.32-8.585 51.422-7.487 14.99-20.502 27.636-35.984 34.968-12.14 5.75-21.926 8.04-36.716 8.593-14.43.54-24.626-.716-35.516-4.376l-7.632-2.564-5.068 3.704c-12.844 9.387-32.714 16.488-51.545 18.42-10.607 1.09-13.916 1.08-24.81-.066z"/></svg>');
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
@ -346,18 +411,18 @@
|
||||
background: darken($ui-base-color, 4%);
|
||||
padding: 20px 0;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
position: relative;
|
||||
padding-right: 280px + 15px;
|
||||
}
|
||||
|
||||
.information-board-sections {
|
||||
&__sections {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
&__section {
|
||||
flex: 1 0 0;
|
||||
font-family: 'mastodon-font-sans-serif', sans-serif;
|
||||
font-size: 16px;
|
||||
@ -382,6 +447,10 @@
|
||||
font-size: 32px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
@ -460,111 +529,282 @@
|
||||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
padding: 50px 0;
|
||||
&.alternative {
|
||||
padding: 10px 0;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
.brand {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
margin-bottom: 10px;
|
||||
|
||||
img {
|
||||
position: static;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-breakpoint) {
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
padding: 0;
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__information,
|
||||
&__forms {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&__call-to-action {
|
||||
margin-bottom: 10px;
|
||||
background: darken($ui-base-color, 4%);
|
||||
border-radius: 4px;
|
||||
padding: 25px 40px;
|
||||
overflow: hidden;
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#mastodon-timeline {
|
||||
display: flex;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
font-family: 'mastodon-font-sans-serif', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
.information-board__section {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo {
|
||||
margin-right: 20px;
|
||||
|
||||
img {
|
||||
height: 50px;
|
||||
width: auto;
|
||||
mix-blend-mode: lighten;
|
||||
}
|
||||
}
|
||||
|
||||
&__information {
|
||||
padding: 45px 40px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__information,
|
||||
&__forms,
|
||||
#mastodon-timeline {
|
||||
box-sizing: border-box;
|
||||
background: $ui-base-color;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 6px rgba($black, 0.1);
|
||||
}
|
||||
|
||||
&__mascot {
|
||||
height: 104px;
|
||||
position: relative;
|
||||
left: -40px;
|
||||
bottom: 25px;
|
||||
|
||||
img {
|
||||
height: 190px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__short-description {
|
||||
.row {
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
.row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
p a {
|
||||
color: $ui-secondary-color;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
color: $primary-text-color;
|
||||
width: 330px;
|
||||
margin-right: 30px;
|
||||
flex: 0 0 auto;
|
||||
background: $ui-base-color;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 6px rgba($black, 0.1);
|
||||
margin-bottom: 0;
|
||||
|
||||
.column-header {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
small {
|
||||
color: $ui-primary-color;
|
||||
|
||||
.column {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
color: $primary-text-color;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
span {
|
||||
color: $ui-secondary-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-mastodon {
|
||||
max-width: 675px;
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
&__hero {
|
||||
margin-bottom: 10px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__forms {
|
||||
height: 100%;
|
||||
|
||||
@media screen and (max-width: $small-breakpoint) {
|
||||
margin-bottom: 10px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0 20px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.separator-or {
|
||||
span {
|
||||
background: darken($ui-base-color, 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.subtle-hint a {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#mastodon-timeline {
|
||||
display: flex;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
font-family: 'mastodon-font-sans-serif', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
color: $primary-text-color;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
.column-header {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
color: $primary-text-color;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.features-list {
|
||||
margin-top: 20px;
|
||||
a {
|
||||
color: $ui-secondary-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.features-list__row {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
justify-content: space-between;
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
&__features {
|
||||
.features-list {
|
||||
margin: 40px 0 !important;
|
||||
}
|
||||
|
||||
.visual {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 15px;
|
||||
&__action {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
display: block;
|
||||
color: $ui-primary-color;
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
.features-list {
|
||||
margin-top: 20px;
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: $ui-primary-color;
|
||||
.features-list__row {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
justify-content: space-between;
|
||||
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.visual {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 15px;
|
||||
|
||||
.fa {
|
||||
display: block;
|
||||
color: $ui-primary-color;
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: $ui-primary-color;
|
||||
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -600,21 +840,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
color: $ui-base-lighter-color;
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
.container {
|
||||
.container-alt {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.information-board {
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: static;
|
||||
margin-top: 20px;
|
||||
@ -626,16 +876,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-wrapper .mascot {
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 689px) {
|
||||
.header-wrapper .mascot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 675px) {
|
||||
@ -651,13 +891,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.header .container,
|
||||
.features .container {
|
||||
.header .container-alt,
|
||||
.features .container-alt {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
.links {
|
||||
padding-top: 15px;
|
||||
background: darken($ui-base-color, 4%);
|
||||
@ -682,10 +921,6 @@
|
||||
margin-top: 30px;
|
||||
padding: 0;
|
||||
|
||||
.floats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
@ -700,16 +935,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.features #mastodon-timeline {
|
||||
height: 70vh;
|
||||
width: 100%;
|
||||
margin-bottom: 50px;
|
||||
|
||||
.column {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cta {
|
||||
@ -720,7 +945,7 @@
|
||||
.features {
|
||||
padding: 30px 0;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
max-width: 820px;
|
||||
|
||||
#mastodon-timeline {
|
||||
@ -772,7 +997,7 @@
|
||||
.features {
|
||||
padding: 10px 0;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -808,17 +1033,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floating {
|
||||
from {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: translate(0, 4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(0, -0);
|
||||
}
|
||||
}
|
||||
|
@ -40,14 +40,20 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.button-alternative {
|
||||
&.button-primary,
|
||||
&.button-alternative,
|
||||
&.button-secondary,
|
||||
&.button-alternative-2 {
|
||||
font-size: 16px;
|
||||
line-height: 36px;
|
||||
height: auto;
|
||||
color: $ui-base-color;
|
||||
background: $ui-primary-color;
|
||||
text-transform: none;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
&.button-alternative {
|
||||
color: $ui-base-color;
|
||||
background: $ui-primary-color;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
@ -56,15 +62,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.button-alternative-2 {
|
||||
background: $ui-base-lighter-color;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: lighten($ui-base-lighter-color, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&.button-secondary {
|
||||
font-size: 16px;
|
||||
line-height: 36px;
|
||||
height: auto;
|
||||
color: $ui-primary-color;
|
||||
text-transform: none;
|
||||
background: transparent;
|
||||
padding: 3px 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $ui-primary-color;
|
||||
|
||||
&:active,
|
||||
@ -433,6 +444,34 @@
|
||||
min-width: 40%;
|
||||
margin: 5px;
|
||||
|
||||
&__actions {
|
||||
background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
opacity: 0;
|
||||
transition: opacity .1s ease;
|
||||
|
||||
.icon-button {
|
||||
flex: 0 1 auto;
|
||||
color: $ui-secondary-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: lighten($ui-secondary-color, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@ -470,10 +509,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-form__upload-thumbnail {
|
||||
@ -481,8 +516,9 @@
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 100px;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4133,8 +4169,12 @@ a.status-card {
|
||||
&,
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4842,3 +4882,31 @@ noscript {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.focal-point {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&.dragging {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&__reticle {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: url('../images/reticle.png') no-repeat 0 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
.container {
|
||||
.container-alt {
|
||||
width: 700px;
|
||||
margin: 0 auto;
|
||||
margin-top: 40px;
|
||||
|
@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
|
||||
|
||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence)
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
|
||||
media_attachments << media_attachment
|
||||
|
||||
next if skip_download?
|
||||
|
@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||
'conversation' => 'ostatus:conversation',
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'Emoji' => 'toot:Emoji',
|
||||
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
|
||||
},
|
||||
],
|
||||
}.freeze
|
||||
|
11
app/lib/fast_geometry_parser.rb
Normal file
11
app/lib/fast_geometry_parser.rb
Normal file
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FastGeometryParser
|
||||
def self.from_file(file)
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
raise Paperclip::Errors::NotIdentifiedByImageMagickError if width.nil?
|
||||
|
||||
Paperclip::Geometry.new(width, height)
|
||||
end
|
||||
end
|
@ -66,4 +66,16 @@ class UserMailer < Devise::Mailer
|
||||
mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
|
||||
end
|
||||
end
|
||||
|
||||
def backup_ready(user, backup)
|
||||
@resource = user
|
||||
@instance = Rails.configuration.x.local_domain
|
||||
@backup = backup
|
||||
|
||||
return if @resource.disabled?
|
||||
|
||||
I18n.with_locale(@resource.locale || I18n.default_locale) do
|
||||
mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
22
app/models/backup.rb
Normal file
22
app/models/backup.rb
Normal file
@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: backups
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# user_id :integer
|
||||
# dump_file_name :string
|
||||
# dump_content_type :string
|
||||
# dump_file_size :integer
|
||||
# dump_updated_at :datetime
|
||||
# processed :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Backup < ApplicationRecord
|
||||
belongs_to :user, inverse_of: :backups
|
||||
|
||||
has_attached_file :dump
|
||||
do_not_validate_attachment_file_type :dump
|
||||
end
|
@ -7,15 +7,9 @@ module AccountAvatar
|
||||
|
||||
class_methods do
|
||||
def avatar_styles(file)
|
||||
styles = {}
|
||||
geometry = Paperclip::Geometry.from_file(file)
|
||||
|
||||
styles[:original] = '120x120#' if geometry.width != geometry.height || geometry.width > 120 || geometry.height > 120
|
||||
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
|
||||
|
||||
styles = { original: { geometry: '120x120#', file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { geometry: '120x120#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
{}
|
||||
end
|
||||
|
||||
private :avatar_styles
|
||||
@ -23,7 +17,7 @@ module AccountAvatar
|
||||
|
||||
included do
|
||||
# Avatar upload
|
||||
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }
|
||||
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :avatar, less_than: 2.megabytes
|
||||
end
|
||||
|
@ -7,15 +7,9 @@ module AccountHeader
|
||||
|
||||
class_methods do
|
||||
def header_styles(file)
|
||||
styles = {}
|
||||
geometry = Paperclip::Geometry.from_file(file)
|
||||
|
||||
styles[:original] = '700x335#' unless geometry.width == 700 && geometry.height == 335
|
||||
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
|
||||
|
||||
styles = { original: { geometry: '700x335#', file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { geometry: '700x335#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
{}
|
||||
end
|
||||
|
||||
private :header_styles
|
||||
@ -23,7 +17,7 @@ module AccountHeader
|
||||
|
||||
included do
|
||||
# Header upload
|
||||
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }
|
||||
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :header, less_than: 2.megabytes
|
||||
end
|
||||
|
@ -53,8 +53,11 @@ module Omniauthable
|
||||
private
|
||||
|
||||
def user_params_from_auth(auth)
|
||||
email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email)
|
||||
email = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email)
|
||||
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
|
||||
assume_verified = strategy.try(:security).try(:assume_email_is_verified)
|
||||
email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
|
||||
email = auth.info.verified_email || auth.info.email
|
||||
email = email_is_verified && !User.exists?(email: auth.info.email) && email
|
||||
|
||||
{
|
||||
email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
||||
|
@ -34,7 +34,18 @@ class MediaAttachment < ApplicationRecord
|
||||
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
|
||||
AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
|
||||
|
||||
IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
|
||||
IMAGE_STYLES = {
|
||||
original: {
|
||||
geometry: '1280x1280>',
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
},
|
||||
|
||||
small: {
|
||||
geometry: '400x400>',
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
},
|
||||
}.freeze
|
||||
|
||||
AUDIO_STYLES = {
|
||||
original: {
|
||||
format: 'mp4',
|
||||
@ -50,6 +61,7 @@ class MediaAttachment < ApplicationRecord
|
||||
},
|
||||
},
|
||||
}.freeze
|
||||
|
||||
VIDEO_STYLES = {
|
||||
small: {
|
||||
convert_options: {
|
||||
@ -97,6 +109,24 @@ class MediaAttachment < ApplicationRecord
|
||||
shortcode
|
||||
end
|
||||
|
||||
def focus=(point)
|
||||
return if point.blank?
|
||||
|
||||
x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
|
||||
|
||||
meta = file.instance_read(:meta) || {}
|
||||
meta['focus'] = { 'x' => x, 'y' => y }
|
||||
|
||||
file.instance_write(:meta, meta)
|
||||
end
|
||||
|
||||
def focus
|
||||
x = file.meta['focus']['x']
|
||||
y = file.meta['focus']['y']
|
||||
|
||||
"#{x},#{y}"
|
||||
end
|
||||
|
||||
before_create :prepare_description, unless: :local?
|
||||
before_create :set_shortcode
|
||||
before_post_process :set_type_and_extension
|
||||
@ -178,7 +208,7 @@ class MediaAttachment < ApplicationRecord
|
||||
end
|
||||
|
||||
def populate_meta
|
||||
meta = {}
|
||||
meta = file.instance_read(:meta) || {}
|
||||
|
||||
file.queued_for_write.each do |style, file|
|
||||
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
||||
@ -188,16 +218,16 @@ class MediaAttachment < ApplicationRecord
|
||||
end
|
||||
|
||||
def image_geometry(file)
|
||||
geo = Paperclip::Geometry.from_file file
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
return {} if width.nil?
|
||||
|
||||
{
|
||||
width: geo.width.to_i,
|
||||
height: geo.height.to_i,
|
||||
size: "#{geo.width.to_i}x#{geo.height.to_i}",
|
||||
aspect: geo.width.to_f / geo.height.to_f,
|
||||
width: width,
|
||||
height: height,
|
||||
size: "#{width}x#{height}",
|
||||
aspect: width.to_f / height.to_f,
|
||||
}
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
{}
|
||||
end
|
||||
|
||||
def video_metadata(file)
|
||||
|
@ -33,7 +33,7 @@ class PreviewCard < ApplicationRecord
|
||||
|
||||
has_and_belongs_to_many :statuses
|
||||
|
||||
has_attached_file :image, styles: { original: '400x400>' }, convert_options: { all: '-quality 80 -strip' }
|
||||
has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' }
|
||||
|
||||
include Attachmentable
|
||||
include Remotable
|
||||
@ -58,10 +58,11 @@ class PreviewCard < ApplicationRecord
|
||||
|
||||
return if file.nil?
|
||||
|
||||
geo = Paperclip::Geometry.from_file(file)
|
||||
self.width = geo.width.to_i
|
||||
self.height = geo.height.to_i
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
nil
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
return nil if width.nil?
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
end
|
||||
end
|
||||
|
@ -34,8 +34,8 @@ class SiteUpload < ApplicationRecord
|
||||
|
||||
return if tempfile.nil?
|
||||
|
||||
geometry = Paperclip::Geometry.from_file(tempfile)
|
||||
self.meta = { width: geometry.width.to_i, height: geometry.height.to_i }
|
||||
width, height = FastImage.size(tempfile.path)
|
||||
self.meta = { width: width, height: height }
|
||||
end
|
||||
|
||||
def clear_cache
|
||||
|
@ -79,7 +79,7 @@ class Status < ApplicationRecord
|
||||
|
||||
scope :not_local_only, -> { where(local_only: [false, nil]) }
|
||||
|
||||
cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
|
||||
cache_associated :account, :application, :media_attachments, :conversation, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, :conversation, mentions: :account], thread: :account
|
||||
|
||||
delegate :domain, to: :account, prefix: true
|
||||
|
||||
|
@ -60,6 +60,7 @@ class User < ApplicationRecord
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
||||
has_many :backups, inverse_of: :user
|
||||
|
||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
||||
validates_with BlacklistedEmailValidator, if: :email_changed?
|
||||
|
@ -15,4 +15,8 @@ class ApplicationPolicy
|
||||
def current_user
|
||||
current_account&.user
|
||||
end
|
||||
|
||||
def user_signed_in?
|
||||
!current_user.nil?
|
||||
end
|
||||
end
|
||||
|
9
app/policies/backup_policy.rb
Normal file
9
app/policies/backup_policy.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BackupPolicy < ApplicationPolicy
|
||||
MIN_AGE = 1.week
|
||||
|
||||
def create?
|
||||
user_signed_in? && current_user.backups.where('created_at >= ?', MIN_AGE.ago).count.zero?
|
||||
end
|
||||
end
|
@ -45,7 +45,7 @@ class AccountRelationshipsPresenter
|
||||
maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
|
||||
|
||||
if maps_for_account.is_a?(Hash)
|
||||
@cached.merge!(maps_for_account)
|
||||
@cached.deep_merge!(maps_for_account)
|
||||
else
|
||||
@uncached_account_ids << account_id
|
||||
end
|
||||
|
@ -48,4 +48,8 @@ class InstancePresenter
|
||||
def thumbnail
|
||||
@thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') }
|
||||
end
|
||||
|
||||
def hero
|
||||
@hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
|
||||
end
|
||||
end
|
||||
|
@ -13,8 +13,8 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer
|
||||
attribute :part_of, if: -> { object.part_of.present? }
|
||||
|
||||
has_one :first, if: -> { object.first.present? }
|
||||
has_many :items, key: :items, if: -> { (object.items.present? || page?) && !ordered? }
|
||||
has_many :items, key: :ordered_items, if: -> { (object.items.present? || page?) && ordered? }
|
||||
has_many :items, key: :items, if: -> { (!object.items.nil? || page?) && !ordered? }
|
||||
has_many :items, key: :ordered_items, if: -> { (!object.items.nil? || page?) && ordered? }
|
||||
|
||||
def type
|
||||
if page?
|
||||
|
@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :type, :media_type, :url
|
||||
attribute :focal_point, if: :focal_point?
|
||||
|
||||
def type
|
||||
'Image'
|
||||
@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
|
||||
def media_type
|
||||
object.content_type
|
||||
end
|
||||
|
||||
def focal_point?
|
||||
object.respond_to?(:meta) && object.meta.is_a?(Hash) && object.meta['focus'].is_a?(Hash)
|
||||
end
|
||||
|
||||
def focal_point
|
||||
[object.meta['focus']['x'], object.meta['focus']['y']]
|
||||
end
|
||||
end
|
||||
|
128
app/services/backup_service.rb
Normal file
128
app/services/backup_service.rb
Normal file
@ -0,0 +1,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rubygems/package'
|
||||
|
||||
class BackupService < BaseService
|
||||
attr_reader :account, :backup, :collection
|
||||
|
||||
def call(backup)
|
||||
@backup = backup
|
||||
@account = backup.user.account
|
||||
|
||||
build_json!
|
||||
build_archive!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_json!
|
||||
@collection = serialize(collection_presenter, ActivityPub::CollectionSerializer)
|
||||
|
||||
account.statuses.with_includes.find_in_batches do |statuses|
|
||||
statuses.each do |status|
|
||||
item = serialize(status, ActivityPub::ActivitySerializer)
|
||||
item.delete(:'@context')
|
||||
|
||||
unless item[:type] == 'Announce' || item[:object][:attachment].blank?
|
||||
item[:object][:attachment].each do |attachment|
|
||||
attachment[:url] = Addressable::URI.parse(attachment[:url]).path.gsub(/\A\/system\//, '')
|
||||
end
|
||||
end
|
||||
|
||||
@collection[:orderedItems] << item
|
||||
end
|
||||
|
||||
GC.start
|
||||
end
|
||||
end
|
||||
|
||||
def build_archive!
|
||||
tmp_file = Tempfile.new(%w(archive .tar.gz))
|
||||
|
||||
File.open(tmp_file, 'wb') do |file|
|
||||
Zlib::GzipWriter.wrap(file) do |gz|
|
||||
Gem::Package::TarWriter.new(gz) do |tar|
|
||||
dump_media_attachments!(tar)
|
||||
dump_outbox!(tar)
|
||||
dump_actor!(tar)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
archive_filename = ['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(2)].join('-') + '.tar.gz'
|
||||
|
||||
@backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
|
||||
@backup.processed = true
|
||||
@backup.save!
|
||||
ensure
|
||||
tmp_file.close
|
||||
tmp_file.unlink
|
||||
end
|
||||
|
||||
def dump_media_attachments!(tar)
|
||||
MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
|
||||
media_attachments.each do |m|
|
||||
download_to_tar(tar, m.file, m.file.path)
|
||||
end
|
||||
|
||||
GC.start
|
||||
end
|
||||
end
|
||||
|
||||
def dump_outbox!(tar)
|
||||
json = Oj.dump(collection)
|
||||
|
||||
tar.add_file_simple('outbox.json', 0o444, json.bytesize) do |io|
|
||||
io.write(json)
|
||||
end
|
||||
end
|
||||
|
||||
def dump_actor!(tar)
|
||||
actor = serialize(account, ActivityPub::ActorSerializer)
|
||||
|
||||
actor[:icon][:url] = 'avatar' + File.extname(actor[:icon][:url]) if actor[:icon]
|
||||
actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image]
|
||||
|
||||
download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists?
|
||||
download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists?
|
||||
|
||||
json = Oj.dump(actor)
|
||||
|
||||
tar.add_file_simple('actor.json', 0o444, json.bytesize) do |io|
|
||||
io.write(json)
|
||||
end
|
||||
|
||||
tar.add_file_simple('key.pem', 0o444, account.private_key.bytesize) do |io|
|
||||
io.write(account.private_key)
|
||||
end
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_outbox_url(account),
|
||||
type: :ordered,
|
||||
size: account.statuses_count,
|
||||
items: []
|
||||
)
|
||||
end
|
||||
|
||||
def serialize(object, serializer)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
object,
|
||||
serializer: serializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json
|
||||
end
|
||||
|
||||
CHUNK_SIZE = 1.megabyte
|
||||
|
||||
def download_to_tar(tar, attachment, filename)
|
||||
adapter = Paperclip.io_adapters.for(attachment)
|
||||
|
||||
tar.add_file_simple(filename, 0o444, adapter.size) do |io|
|
||||
while (buffer = adapter.read(CHUNK_SIZE))
|
||||
io.write(buffer)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
14
app/views/about/_forms.html.haml
Normal file
14
app/views/about/_forms.html.haml
Normal file
@ -0,0 +1,14 @@
|
||||
- if @instance_presenter.open_registrations
|
||||
= render 'registration'
|
||||
- else
|
||||
- if @instance_presenter.closed_registrations_message.blank?
|
||||
%p= t('about.closed_registrations')
|
||||
- else
|
||||
= @instance_presenter.closed_registrations_message.html_safe
|
||||
|
||||
= link_to t('auth.register'), 'https://joinmastodon.org', class: 'button button-primary'
|
||||
|
||||
.separator-or
|
||||
%span= t('auth.or')
|
||||
|
||||
= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn'
|
@ -1,4 +1,4 @@
|
||||
.container.links
|
||||
.container-alt.links
|
||||
.brand
|
||||
= link_to root_url do
|
||||
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
|
||||
|
@ -10,6 +10,6 @@
|
||||
= 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' }
|
||||
|
||||
.actions
|
||||
= f.button :button, t('auth.register'), type: :submit, class: 'button button-alternative'
|
||||
= f.button :button, t('auth.register'), type: :submit, class: 'button button-primary'
|
||||
|
||||
%p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)
|
||||
|
@ -9,34 +9,34 @@
|
||||
.header
|
||||
= render 'links'
|
||||
|
||||
.container.hero
|
||||
.container-alt.hero
|
||||
.heading
|
||||
%h3= t('about.description_headline', domain: site_hostname)
|
||||
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
|
||||
|
||||
.information-board
|
||||
.container
|
||||
.information-board-sections
|
||||
.section
|
||||
.container-alt
|
||||
.information-board__sections
|
||||
.information-board__section
|
||||
%span= t 'about.user_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.user_count
|
||||
%span= t 'about.user_count_after'
|
||||
.section
|
||||
.information-board__section
|
||||
%span= t 'about.status_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.status_count
|
||||
%span= t 'about.status_count_after'
|
||||
.section
|
||||
.information-board__section
|
||||
%span= t 'about.domain_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.domain_count
|
||||
%span= t 'about.domain_count_after'
|
||||
= render 'contact', contact: @instance_presenter
|
||||
|
||||
.extended-description
|
||||
.container
|
||||
.container-alt
|
||||
= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
|
||||
|
||||
.footer-links
|
||||
.container
|
||||
.container-alt
|
||||
%p
|
||||
= link_to t('about.source_code'), @instance_presenter.source_url
|
||||
- if @instance_presenter.commit_hash == ""
|
||||
|
@ -5,62 +5,74 @@
|
||||
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
|
||||
= render partial: 'shared/og'
|
||||
|
||||
.landing-page
|
||||
.header-wrapper
|
||||
.mascot-container
|
||||
= image_tag asset_pack_path('elephant-fren.png'), alt: '', role: 'presentation', class: 'mascot'
|
||||
.landing-page.alternative
|
||||
.container
|
||||
.row
|
||||
.column-4.hide-sm.show-xs.show-m
|
||||
.landing-page__forms
|
||||
.brand
|
||||
= link_to root_url do
|
||||
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
|
||||
|
||||
.header
|
||||
= render 'links'
|
||||
.hide-xs
|
||||
= render 'forms'
|
||||
|
||||
.container.hero
|
||||
.floats
|
||||
%div{ role: 'presentation', class: 'float-1' }
|
||||
%div{ role: 'presentation', class: 'float-2' }
|
||||
%div{ role: 'presentation', class: 'float-3' }
|
||||
.heading
|
||||
%h1
|
||||
= @instance_presenter.site_title
|
||||
%small= t 'about.hosted_on', domain: site_hostname
|
||||
- if @instance_presenter.open_registrations
|
||||
= render 'registration'
|
||||
- else
|
||||
.closed-registrations-message
|
||||
%div
|
||||
- if @instance_presenter.closed_registrations_message.blank?
|
||||
%p= t('about.closed_registrations')
|
||||
- else
|
||||
= @instance_presenter.closed_registrations_message.html_safe
|
||||
.column-7.column-9-sm
|
||||
.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
|
||||
|
||||
= simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f|
|
||||
= f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
|
||||
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
|
||||
.landing-page__information
|
||||
.landing-page__short-description
|
||||
.row
|
||||
.landing-page__logo.hide-xs
|
||||
= image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
|
||||
|
||||
.actions
|
||||
= f.button :button, t('auth.login'), type: :submit
|
||||
= link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
|
||||
%h1
|
||||
= @instance_presenter.site_title
|
||||
%small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
|
||||
|
||||
.about-short
|
||||
.container
|
||||
%h3= t('about.description_headline', domain: site_hostname)
|
||||
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
|
||||
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
|
||||
|
||||
.features
|
||||
.container
|
||||
- if Setting.timeline_preview
|
||||
#mastodon-timeline{ data: { props: Oj.dump(default_props) } }
|
||||
.show-xs
|
||||
.landing-page__forms
|
||||
= render 'forms'
|
||||
.landing-page__call-to-action.hide-xs
|
||||
.row
|
||||
.column-5
|
||||
.landing-page__mascot
|
||||
= image_tag asset_pack_path('elephant_ui_plane.svg')
|
||||
.column-5
|
||||
.information-board__section
|
||||
%span= t 'about.user_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.user_count
|
||||
%span= t 'about.user_count_after'
|
||||
.column-5
|
||||
.information-board__section
|
||||
%span= t 'about.status_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.status_count
|
||||
%span= t 'about.status_count_after'
|
||||
.landing-page__information
|
||||
.landing-page__features
|
||||
%h3= t 'about.what_is_mastodon'
|
||||
%p= t 'about.about_mastodon_html'
|
||||
|
||||
.about-mastodon
|
||||
%h3= t 'about.what_is_mastodon'
|
||||
%p= t 'about.about_mastodon_html'
|
||||
= link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary'
|
||||
= render 'features'
|
||||
.footer-links
|
||||
.container
|
||||
%p
|
||||
= link_to t('about.source_code'), @instance_presenter.source_url
|
||||
- if @instance_presenter.commit_hash == ""
|
||||
%strong= " (#{@instance_presenter.version_number})"
|
||||
- else
|
||||
%strong= " (#{@instance_presenter.version_number}, "
|
||||
%strong= " #{@instance_presenter.commit_hash})"
|
||||
= render 'features'
|
||||
|
||||
.landing-page__features__action
|
||||
= link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
|
||||
|
||||
.landing-page__footer
|
||||
%p
|
||||
= link_to t('about.source_code'), @instance_presenter.source_url
|
||||
= " (#{@instance_presenter.version_number})"
|
||||
|
||||
.column-4.column-6-sm.column-flex
|
||||
.show-sm.hide-xs
|
||||
.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
|
||||
#mastodon-timeline{ data: { props: Oj.dump(default_props) } }
|
||||
|
@ -7,5 +7,5 @@
|
||||
= render 'links'
|
||||
|
||||
.extended-description
|
||||
.container
|
||||
.container-alt
|
||||
= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
|
||||
|
@ -12,6 +12,7 @@
|
||||
|
||||
.fields-group
|
||||
= f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
|
||||
= f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
|
||||
|
||||
%hr/
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
- content_for :content do
|
||||
.container
|
||||
.container-alt
|
||||
.logo-container
|
||||
%h1
|
||||
= link_to root_path do
|
||||
|
@ -8,7 +8,7 @@
|
||||
= link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
|
||||
= fa_icon 'sign-out'
|
||||
|
||||
.container= yield
|
||||
.container-alt= yield
|
||||
.modal-layout__mastodon
|
||||
%div
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
- content_for :content do
|
||||
.container= yield
|
||||
.container-alt= yield
|
||||
.footer
|
||||
- if !user_signed_in? && single_user_mode?
|
||||
%span.single-user-login
|
||||
|
@ -20,3 +20,26 @@
|
||||
%th= t('exports.mutes')
|
||||
%td= @export.total_mutes
|
||||
%td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
|
||||
|
||||
%p.muted-hint= t('exports.archive_takeout.hint_html')
|
||||
|
||||
- if policy(:backup).create?
|
||||
%p= link_to t('exports.archive_takeout.request'), settings_export_path, class: 'button', method: :post
|
||||
|
||||
- unless @backups.empty?
|
||||
.table-wrapper
|
||||
%table.table
|
||||
%thead
|
||||
%tr
|
||||
%th= t('exports.archive_takeout.date')
|
||||
%th= t('exports.archive_takeout.size')
|
||||
%th
|
||||
%tbody
|
||||
- @backups.each do |backup|
|
||||
%tr
|
||||
%td= l backup.created_at
|
||||
- if backup.processed?
|
||||
%td= number_to_human_size backup.dump_file_size
|
||||
%td= table_link_to 'download', t('exports.archive_takeout.download'), backup.dump.url
|
||||
- else
|
||||
%td{ colspan: 2 }= t('exports.archive_takeout.in_progress')
|
||||
|
59
app/views/user_mailer/backup_ready.html.haml
Normal file
59
app/views/user_mailer/backup_ready.html.haml
Normal file
@ -0,0 +1,59 @@
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.email-body
|
||||
.email-container
|
||||
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.content-cell.hero
|
||||
.email-row
|
||||
.col-6
|
||||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.column-cell.text-center.padded
|
||||
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td
|
||||
= image_tag full_pack_url('icon_file_download.png'), alt: ''
|
||||
|
||||
%h1= t 'user_mailer.backup_ready.title'
|
||||
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.email-body
|
||||
.email-container
|
||||
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.content-cell.content-start
|
||||
.email-row
|
||||
.col-6
|
||||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.column-cell.text-center
|
||||
%p= t 'user_mailer.backup_ready.explanation'
|
||||
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.email-body
|
||||
.email-container
|
||||
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.content-cell
|
||||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.column-cell.button-cell
|
||||
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.button-primary
|
||||
= link_to full_asset_url(@backup.dump.url) do
|
||||
%span= t 'exports.archive_takeout.download'
|
7
app/views/user_mailer/backup_ready.text.erb
Normal file
7
app/views/user_mailer/backup_ready.text.erb
Normal file
@ -0,0 +1,7 @@
|
||||
<%= t 'user_mailer.backup_ready.title' %>
|
||||
|
||||
===
|
||||
|
||||
<%= t 'user_mailer.backup_ready.explanation' %>
|
||||
|
||||
=> <%= full_asset_url(@backup.dump.url) %>
|
17
app/workers/backup_worker.rb
Normal file
17
app/workers/backup_worker.rb
Normal file
@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BackupWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'pull'
|
||||
|
||||
def perform(backup_id)
|
||||
backup = Backup.find(backup_id)
|
||||
user = backup.user
|
||||
|
||||
BackupService.new.call(backup)
|
||||
|
||||
user.backups.where.not(id: backup.id).destroy_all
|
||||
UserMailer.backup_ready(user, backup).deliver_later
|
||||
end
|
||||
end
|
16
app/workers/scheduler/backup_cleanup_scheduler.rb
Normal file
16
app/workers/scheduler/backup_cleanup_scheduler.rb
Normal file
@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
require 'sidekiq-scheduler'
|
||||
|
||||
class Scheduler::BackupCleanupScheduler
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform
|
||||
old_backups.find_each(&:destroy!)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def old_backups
|
||||
Backup.where('created_at < ?', 7.days.ago)
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user