diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aaca197c3..01c6314c9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,6 +148,8 @@ docker: image: docker:20.10.17 services: - docker:20.10.17-dind + tags: + - dind # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df script: - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin diff --git a/app/images/reticle.png b/app/images/reticle.png deleted file mode 100644 index 1bcb3d261..000000000 Binary files a/app/images/reticle.png and /dev/null differ diff --git a/app/soapbox/components/attachment-thumbs.tsx b/app/soapbox/components/attachment-thumbs.tsx index 37b9fc9c6..3ac1dbf5f 100644 --- a/app/soapbox/components/attachment-thumbs.tsx +++ b/app/soapbox/components/attachment-thumbs.tsx @@ -6,9 +6,10 @@ import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import type { List as ImmutableList } from 'immutable'; +import type { Attachment } from 'soapbox/types/entities'; interface IAttachmentThumbs { - media: ImmutableList> + media: ImmutableList onClick?(): void sensitive?: boolean } @@ -18,7 +19,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => { const dispatch = useDispatch(); const renderLoading = () =>
; - const onOpenMedia = (media: Immutable.Record, index: number) => dispatch(openModal('MEDIA', { media, index })); + const onOpenMedia = (media: ImmutableList, index: number) => dispatch(openModal('MEDIA', { media, index })); return (
@@ -30,6 +31,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => { height={50} compact sensitive={sensitive} + visible /> )} diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx index dfb9c7df5..4f672865b 100644 --- a/app/soapbox/components/media_gallery.tsx +++ b/app/soapbox/components/media_gallery.tsx @@ -263,14 +263,13 @@ const Item: React.FC = ({ interface IMediaGallery { sensitive?: boolean, media: ImmutableList, - size: number, height: number, onOpenMedia: (media: ImmutableList, index: number) => void, - defaultWidth: number, - cacheWidth: (width: number) => void, + defaultWidth?: number, + cacheWidth?: (width: number) => void, visible?: boolean, onToggleVisibility?: () => void, - displayMedia: string, + displayMedia?: string, compact: boolean, } @@ -278,7 +277,7 @@ const MediaGallery: React.FC = (props) => { const { media, sensitive = false, - defaultWidth, + defaultWidth = 0, onToggleVisibility, onOpenMedia, cacheWidth, diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index 3775a107c..5baabeb86 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -1,6 +1,6 @@ import classNames from 'clsx'; import React, { useEffect, useState } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, FormattedMessage } from 'react-intl'; import { usePopper } from 'react-popper'; import { useHistory } from 'react-router-dom'; @@ -15,9 +15,10 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import { UserPanel } from 'soapbox/features/ui/util/async-components'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; +import { isLocal } from 'soapbox/utils/accounts'; import { showProfileHoverCard } from './hover_ref_wrapper'; -import { Card, CardBody, Stack, Text } from './ui'; +import { Card, CardBody, HStack, Icon, Stack, Text } from './ui'; import type { AppDispatch } from 'soapbox/store'; import type { Account } from 'soapbox/types/entities'; @@ -60,6 +61,7 @@ interface IProfileHoverCard { export const ProfileHoverCard: React.FC = ({ visible = true }) => { const dispatch = useAppDispatch(); const history = useHistory(); + const intl = useIntl(); const [popperElement, setPopperElement] = useState(null); @@ -88,6 +90,7 @@ export const ProfileHoverCard: React.FC = ({ visible = true } if (!account) return null; const accountBio = { __html: account.note_emojified }; + const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' }); const followedBy = me !== account.id && account.relationship?.followed_by === true; return ( @@ -116,6 +119,23 @@ export const ProfileHoverCard: React.FC = ({ visible = true } )} + {isLocal(account) ? ( + + + + + + + + ) : null} + {account.source.get('note', '').length > 0 && ( )} diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 642807dad..4846e40ca 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -376,7 +376,7 @@ const StatusActionBar: React.FC = ({ menu.push({ text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), action: handlePinClick, - icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), + icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), }); } else { if (status.visibility === 'private') { diff --git a/app/soapbox/features/chats/components/chat-message-list.tsx b/app/soapbox/features/chats/components/chat-message-list.tsx index ad1a2f82d..9303f2b33 100644 --- a/app/soapbox/features/chats/components/chat-message-list.tsx +++ b/app/soapbox/features/chats/components/chat-message-list.tsx @@ -178,6 +178,7 @@ const ChatMessageList: React.FC = ({ chatId, chatMessageIds, a media={ImmutableList([attachment])} height={120} onOpenMedia={onOpenMedia} + visible /> )} diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index fd339cf5c..222c44434 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -48,6 +48,7 @@ const messages = defineMessages({ promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' }, authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' }, authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' }, + displayCtaLabel: { id: 'soapbox_config.cta_label', defaultMessage: 'Display call to action panels if not authenticated' }, singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' }, singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' }, singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' }, @@ -261,6 +262,13 @@ const SoapboxConfig: React.FC = () => { /> + + e.target.checked)} + /> + + { + const { displayCta } = useSoapboxConfig(); const siteTitle = useAppSelector(state => state.instance.title); + if (!displayCta) return null; + return ( diff --git a/app/soapbox/features/ui/components/cta-banner.tsx b/app/soapbox/features/ui/components/cta-banner.tsx index 9d83c6702..fa4ceba90 100644 --- a/app/soapbox/features/ui/components/cta-banner.tsx +++ b/app/soapbox/features/ui/components/cta-banner.tsx @@ -5,11 +5,11 @@ import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui'; import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; const CtaBanner = () => { - const { singleUserMode } = useSoapboxConfig(); + const { displayCta, singleUserMode } = useSoapboxConfig(); const siteTitle = useAppSelector((state) => state.instance.title); const me = useAppSelector((state) => state.me); - if (me || singleUserMode) return null; + if (me || !displayCta || singleUserMode) return null; return (
diff --git a/app/soapbox/features/ui/components/focal_point_modal.js b/app/soapbox/features/ui/components/focal_point_modal.js deleted file mode 100644 index b45b367eb..000000000 --- a/app/soapbox/features/ui/components/focal_point_modal.js +++ /dev/null @@ -1,125 +0,0 @@ -import classNames from 'clsx'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { changeUploadCompose } from '../../../actions/compose'; -import { getPointerPosition } from '../../video'; - -import ImageLoader from './image_loader'; - -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)}` })); - }, - -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -class FocalPointModal extends ImmutablePureComponent { - - static propTypes = { - media: ImmutablePropTypes.map.isRequired, - }; - - state = { - x: 0, - y: 0, - focusX: 0, - focusY: 0, - dragging: false, - }; - - componentDidMount() { - this.updatePositionFromMedia(this.props.media); - } - - componentDidUpdate(prevProps) { - const { media } = this.props; - if (prevProps.media.get('id') !== media.get('id')) { - this.updatePositionFromMedia(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 ( -
-
- - -
-
-
-
- ); - } - -} diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index 8ee7b5fc5..268bd1b44 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -15,7 +15,6 @@ import { ListAdder, MissingDescriptionModal, ActionsModal, - FocalPointModal, HotkeysModal, ComposeModal, ReplyMentionsModal, @@ -55,7 +54,6 @@ const MODAL_COMPONENTS = { 'ACTIONS': ActionsModal, 'EMBED': EmbedModal, 'LIST_EDITOR': ListEditor, - 'FOCAL_POINT': FocalPointModal, 'LIST_ADDER': ListAdder, 'HOTKEYS': HotkeysModal, 'COMPOSE': ComposeModal, diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index bbdf70384..a2f6bfcb2 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -146,10 +146,6 @@ export function ActionsModal() { return import(/* webpackChunkName: "features/ui" */'../components/actions_modal'); } -export function FocalPointModal() { - return import(/* webpackChunkName: "features/ui" */'../components/focal_point_modal'); -} - export function HotkeysModal() { return import(/* webpackChunkName: "features/ui" */'../components/hotkeys_modal'); } diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 70a7b3902..12d21681d 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -49,6 +49,7 @@ "account.requested": "Oczekująca prośba, kliknij aby anulować", "account.requested_small": "Oczekująca prośba", "account.search": "Szukaj wpisów @{name}", + "account.search_self": "Szukaj własnych wpisów", "account.share": "Udostępnij profil @{name}", "account.show_reblogs": "Pokazuj podbicia od @{name}", "account.subscribe": "Subskrybuj wpisy @{name}", @@ -67,6 +68,18 @@ "account.verified": "Zweryfikowane konto", "account.welcome": "Welcome", "account_gallery.none": "Brak zawartości multimedialnej do wyświetlenia.", + "account_moderation_modal.admin_fe": "Otwórz w AdminFE", + "account_moderation_modal.fields.account_role": "Poziom uprawnień", + "account_moderation_modal.fields.badges": "Niestandaradowe odznaki", + "account_moderation_modal.fields.deactivate": "Dezaktywuj konto", + "account_moderation_modal.fields.delete": "Usuń konto", + "account_moderation_modal.fields.suggested": "Proponuj obserwację tego konta", + "account_moderation_modal.fields.verified": "Zweryfikowane konto", + "account_moderation_modal.info.id": "ID: {id}", + "account_moderation_modal.roles.admin": "Administrator", + "account_moderation_modal.roles.moderator": "Moderator", + "account_moderation_modal.roles.user": "Użytkownik", + "account_moderation_modal.title": "Moderuj @{acct}", "account_note.hint": "Możesz pozostawić dla siebie notatkę o tym użytkowniku (tylko ty ją zobaczysz):", "account_note.placeholder": "Nie wprowadzono opisu", "account_note.save": "Zapisz", @@ -125,6 +138,7 @@ "admin.users.actions.unsuggest_user": "Przestań polecać @{name}", "admin.users.actions.unverify_user": "Cofnij weryfikację @{name}", "admin.users.actions.verify_user": "Weryfikuj @{name}", + "admin.users.badges_saved_message": "Zaktualizowano niestandardowe odznaki.", "admin.users.remove_donor_message": "Usunięto @{acct} ze wspierających", "admin.users.set_donor_message": "Ustawiono @{acct} jako wspierającego", "admin.users.user_deactivated_message": "Zdezaktywowano @{acct}", @@ -173,6 +187,7 @@ "backups.empty_message": "Nie znaleziono kopii zapasowych. {action}", "backups.empty_message.action": "Chcesz utworzyć?", "backups.pending": "Oczekująca", + "badge_input.placeholder": "Wprowadź odznakę…", "beta.also_available": "Dostępne w językach:", "birthday_panel.title": "Urodziny", "birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.", @@ -736,8 +751,10 @@ "migration.fields.acct.placeholder": "konto@domena", "migration.fields.confirm_password.label": "Obecne hasło", "migration.hint": "Ta opcja przeniesie Twoich obserwujących na nowe konto. Żadne inne dane nie zostaną przeniesione. Aby dokonać migracji, musisz najpierw {link} na swoim nowym koncie.", + "migration.hint.cooldown_period": "Jeżeli przemigrujesz swoje konto, nie będziesz móc wykonać kolejnej migracji przez {cooldownPeriod, plural, one {jeden dzień} other {kolejne # dni}}.", "migration.hint.link": "utworzyć alias konta", "migration.move_account.fail": "Przenoszenie konta nie powiodło się.", + "migration.move_account.fail.cooldown_period": "Niedawno migrowałeś(-aś) swoje konto. Spróbuj ponownie później.", "migration.move_account.success": "Pomyślnie przeniesiono konto.", "migration.submit": "Przenieś obserwujących", "missing_description_modal.cancel": "Anuluj", @@ -747,6 +764,9 @@ "missing_indicator.label": "Nie znaleziono", "missing_indicator.sublabel": "Nie można odnaleźć tego zasobu", "mobile.also_available": "Dostępne w językach:", + "moderation_overlay.contact": "Kontakt", + "moderation_overlay.hide": "Ukryj", + "moderation_overlay.show": "Wyświetl", "morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.", "morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.", "mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?", @@ -843,6 +863,10 @@ "onboarding.display_name.subtitle": "Możesz ją zawsze zmienić później.", "onboarding.display_name.title": "Wybierz wyświetlaną nazwę", "onboarding.done": "Gotowe", + "onboarding.fediverse.its_you": "Oto Twoje konto! Inni ludzie mogą Cię obserwować z innych serwerów używając pełnej @nazwy.", + "onboarding.fediverse.next": "Dalej", + "onboarding.fediverse.title": "{siteTitle} to tylko jedna z części Fediwersum", + "onboarding.fediverse.other_instances": "Kiedy przeglądasz oś czasum, zwróć uwagę na pełną nazwę użytkownika po znaku @, aby wiedzieć z którego serwera pochodzi wpis.", "onboarding.finished.message": "Cieszymy się, że możemy powitać Cię w naszej społeczności! Naciśnij poniższy przycisk, aby rozpocząć.", "onboarding.finished.title": "Wprowadzenie ukończone", "onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu", @@ -1008,6 +1032,7 @@ "report.target": "Zgłaszanie {target}", "reset_password.fail": "Token wygasł, spróbuj ponownie.", "reset_password.header": "Ustaw nowe hasło", + "save": "Zapisz", "schedule.post_time": "Data/godzina publikacji", "schedule.remove": "Usuń zaplanowany wpis", "schedule_button.add_schedule": "Zaplanuj wpis na później", @@ -1128,7 +1153,7 @@ "sponsored.info.title": "Dlaczego widzę tę reklamę?", "sponsored.subtitle": "Wpis sponsorowany", "status.actions.more": "Więcej", - "status.admin_account": "Otwórz interfejs moderacyjny dla @{name}", + "status.admin_account": "Moderuj @{name}", "status.admin_status": "Otwórz ten wpis w interfejsie moderacyjnym", "status.block": "Zablokuj @{name}", "status.bookmark": "Dodaj do zakładek", diff --git a/app/soapbox/normalizers/chat_message.ts b/app/soapbox/normalizers/chat_message.ts index 71536acb5..f89af4de2 100644 --- a/app/soapbox/normalizers/chat_message.ts +++ b/app/soapbox/normalizers/chat_message.ts @@ -5,6 +5,8 @@ import { fromJS, } from 'immutable'; +import { normalizeAttachment } from 'soapbox/normalizers/attachment'; + import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; export const ChatMessageRecord = ImmutableRecord({ @@ -22,8 +24,14 @@ export const ChatMessageRecord = ImmutableRecord({ pending: false, }); +const normalizeMedia = (status: ImmutableMap) => { + return status.update('attachment', null, normalizeAttachment); +}; + export const normalizeChatMessage = (chatMessage: Record) => { return ChatMessageRecord( - ImmutableMap(fromJS(chatMessage)), + ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => { + normalizeMedia(chatMessage); + }), ); }; diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index 75426e6c0..d9f1e5bb3 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -112,6 +112,7 @@ export const SoapboxConfigRecord = ImmutableRecord({ singleUserModeProfile: '', linkFooterMessage: '', links: ImmutableMap(), + displayCta: true, }, 'SoapboxConfig'); type SoapboxConfigMap = ImmutableMap; diff --git a/app/soapbox/pages/default_page.tsx b/app/soapbox/pages/default_page.tsx index c013eb63d..9e918cc4e 100644 --- a/app/soapbox/pages/default_page.tsx +++ b/app/soapbox/pages/default_page.tsx @@ -39,7 +39,7 @@ const DefaultPage: React.FC = ({ children }) => { {Component => } )} - {features.suggestions && ( + {me && features.suggestions && ( {Component => } diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx index dc8c2c9f4..de9375ace 100644 --- a/app/soapbox/pages/home_page.tsx +++ b/app/soapbox/pages/home_page.tsx @@ -103,7 +103,7 @@ const HomePage: React.FC = ({ children }) => { {Component => } )} - {features.suggestions && ( + {me && features.suggestions && ( {Component => } diff --git a/app/soapbox/pages/profile_page.tsx b/app/soapbox/pages/profile_page.tsx index 9f57e23ab..1cc5f64fe 100644 --- a/app/soapbox/pages/profile_page.tsx +++ b/app/soapbox/pages/profile_page.tsx @@ -137,7 +137,7 @@ const ProfilePage: React.FC = ({ params, children }) => { {Component => } - ) : features.suggestions && ( + ) : me && features.suggestions && ( {Component => } diff --git a/app/soapbox/pages/status_page.tsx b/app/soapbox/pages/status_page.tsx index 414df6783..a296b8fe0 100644 --- a/app/soapbox/pages/status_page.tsx +++ b/app/soapbox/pages/status_page.tsx @@ -43,7 +43,7 @@ const StatusPage: React.FC = ({ children }) => { {Component => } )} - {features.suggestions && ( + {me && features.suggestions && ( {Component => } diff --git a/app/styles/components/compose-form.scss b/app/styles/components/compose-form.scss index 890016d61..0d76263d3 100644 --- a/app/styles/components/compose-form.scss +++ b/app/styles/components/compose-form.scss @@ -242,39 +242,3 @@ @apply block shadow-md; } } - -.focal-point { - position: relative; - cursor: pointer; - overflow: hidden; - - &.dragging { - cursor: move; - } - - img { - max-width: 80vw; - max-height: 80vh; - width: auto; - height: auto; - margin: auto; - } - - &__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; - } -} diff --git a/app/styles/components/modal.scss b/app/styles/components/modal.scss index 435c07c65..4410bdae0 100644 --- a/app/styles/components/modal.scss +++ b/app/styles/components/modal.scss @@ -387,12 +387,6 @@ } } -.focal-point-modal { - max-width: 80vw; - max-height: 80vh; - position: relative; -} - .column-inline-form { padding: 7px 15px; padding-right: 5px;