Merge pull request #2870 from Human-Connection/migrate-styleguide-card

refactor: migrate card component
This commit is contained in:
Robert Schäfer 2020-02-21 11:59:52 +01:00 committed by GitHub
commit fa32c5789b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 1718 additions and 1777 deletions

View File

@ -30,7 +30,7 @@ Then("my comment should be successfully created", () => {
});
Then("I should see my comment", () => {
cy.get("div.comment p")
cy.get("article.comment-card p")
.should("contain", "Human Connection rocks")
.get(".user-avatar img")
.should("have.attr", "src")
@ -40,12 +40,12 @@ Then("I should see my comment", () => {
});
Then("I should see the entirety of my comment", () => {
cy.get("div.comment")
cy.get("article.comment-card")
.should("not.contain", "show more")
});
Then("I should see an abreviated version of my comment", () => {
cy.get("div.comment")
cy.get("article.comment-card")
.should("contain", "show more")
});
@ -60,7 +60,7 @@ Then("it should create a mention in the CommentForm", () => {
})
When("I open the content menu of post {string}", (title)=> {
cy.contains('.post-card', title)
cy.contains('.post-teaser', title)
.find('.content-menu .base-button')
.click()
})
@ -77,9 +77,10 @@ Then("there is no button to pin a post", () => {
})
And("the post with title {string} has a ribbon for pinned posts", (title) => {
cy.get("article.post-card").contains(title)
cy.get(".post-teaser").contains(title)
.parent()
.find("div.ribbon.ribbon--pinned")
.parent()
.find(".ribbon.--pinned")
.should("contain", "Announcement")
})
@ -111,7 +112,7 @@ Then("I add all required fields", () => {
.get(".categories-select .base-button")
.first()
.click()
.get('.ds-flex-item > .ds-form-item .ds-select ')
.get('.base-card > .select-field input')
.click()
.get('.ds-select-option')
.eq(languages.findIndex(l => l.code === 'en'))
@ -119,7 +120,7 @@ Then("I add all required fields", () => {
})
Then("the post was saved successfully with the {string} teaser image", condition => {
cy.get(".ds-card-content > .ds-heading")
cy.get(".base-card > .title")
.should("contain", condition === 'updated' ? 'to be updated' : 'new post')
.get(".content")
.should("contain", condition === 'updated' ? 'successfully updated' : 'new post content')
@ -128,25 +129,22 @@ Then("the post was saved successfully with the {string} teaser image", condition
.and("contains", condition === 'updated' ? 'humanconnection' : 'onourjourney')
})
Then("the first image should be removed from the preview", () => {
cy.fixture("humanconnection.png").as('postTeaserImage').then(function() {
cy.get("#postdropzone")
.children()
.get('img.thumbnail-preview')
.should('have.length', 1)
.and('have.attr', 'src')
.and('contain', this.postTeaserImage)
})
Then("the first image should not be displayed anymore", () => {
cy.get(".hero-image")
.children()
.get('.hero-image > .image')
.should('have.length', 1)
.and('have.attr', 'src')
})
Then('the {string} post was saved successfully without a teaser image', condition => {
cy.get(".ds-card-content > .ds-heading")
cy.get(".base-card > .title")
.should("contain", condition === 'updated' ? 'to be updated' : 'new post')
.get(".content")
.should("contain", condition === 'updated' ? 'successfully updated' : 'new post content')
.get('.post-page')
.should('exist')
.get('.post-page img.ds-card-image')
.get('.hero-image > .image')
.should('not.exist')
})
@ -156,12 +154,12 @@ Then('I should be able to remove it', () => {
})
When('my post has a teaser image', () => {
cy.get('.contribution-image')
cy.get('.contribution-form .image')
.should('exist')
.and('have.attr', 'src')
})
Then('I should be able to remove the image', () => {
cy.get('.delete-image')
cy.get('.dz-message > .base-button')
.click()
})
})

View File

@ -29,7 +29,7 @@ When("I visit another user's profile page", () => {
});
Then("I cannot upload a picture", () => {
cy.get(".ds-card-content")
cy.get(".base-card")
.children()
.should("not.have.id", "customdropzone")
.should("have.class", "user-avatar");

View File

@ -12,7 +12,7 @@ let annoyingUserWhoMutedModeratorTitle = 'Fake news'
const savePostTitle = $post => {
return $post
.first()
.find('.ds-heading')
.find('.title')
.first()
.invoke('text')
.then(title => {
@ -51,7 +51,7 @@ Given('I am logged in with a {string} role', role => {
})
When('I click on "Report Post" from the content menu of the post', () => {
cy.contains('.ds-card', davidIrvingPostTitle)
cy.contains('.base-card', davidIrvingPostTitle)
.find('.content-menu .base-button')
.click({force: true})
@ -61,7 +61,7 @@ When('I click on "Report Post" from the content menu of the post', () => {
})
When('I click on "Report User" from the content menu in the user info box', () => {
cy.contains('.ds-card', davidIrvingPostTitle)
cy.contains('.base-card', davidIrvingPostTitle)
.get('.user-content-menu .base-button')
.click({ force: true })
@ -78,7 +78,7 @@ When('I click on the author', () => {
When('I report the author', () => {
cy.get('.page-name-profile-id-slug').then(() => {
invokeReportOnElement('.ds-card').then(() => {
invokeReportOnElement('.base-card').then(() => {
cy.get('button')
.contains('Send')
.click()
@ -169,7 +169,7 @@ Then('each list item links to the post page', () => {
Then('I can visit the post page', () => {
cy.contains(annoyingUserWhoMutedModeratorTitle).click()
cy.location('pathname').should('contain', '/post')
.get('h3').should('contain', annoyingUserWhoMutedModeratorTitle)
.get('title').should('contain', annoyingUserWhoMutedModeratorTitle)
})
When("they have a post someone has reported", () => {

View File

@ -1,6 +1,6 @@
import { When, Then } from "cypress-cucumber-preprocessor/steps";
When("I search for {string}", value => {
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(value);
});
@ -25,7 +25,7 @@ Then("the search should contain the annoying user", () => {
expect($li).to.have.length(1);
})
cy.get(".ds-select-dropdown .user-teaser .slug").should("contain", '@spammy-spammer');
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type("{esc}");
})
@ -44,21 +44,21 @@ Then("I should see the following users in the select dropdown:", table => {
});
When("I type {string} and press Enter", value => {
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(value)
.type("{enter}", { force: true });
});
When("I type {string} and press escape", value => {
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(value)
.type("{esc}");
});
Then("the search field should clear", () => {
cy.get(".searchable-input .ds-select-search").should("have.text", "");
cy.get(".searchable-input .ds-select input").should("have.text", "");
});
When("I select a post entry", () => {

View File

@ -80,7 +80,7 @@ Then('I should be on the {string} page', page => {
.should(loc => {
expect(loc.pathname).to.eq(page)
})
.get('h3')
.get('h2')
.should('contain', 'Social media')
})
@ -112,7 +112,7 @@ Given('I have added a social media link', () => {
})
Then('they should be able to see my social media links', () => {
cy.get('.ds-card-content')
cy.get('.base-card')
.contains('Where else can I find Peter Pan?')
.get('a[href="https://freeradical.zone/peter-pan"]')
.should('have.length', 1)

View File

@ -73,7 +73,7 @@ Given("the {string} user searches for {string}", (_, postTitle) => {
})
})
.then(user => cy.login(user))
cy.get(".searchable-input .ds-select-search")
cy.get(".searchable-input .ds-select input")
.focus()
.type(postTitle);
});
@ -295,14 +295,14 @@ Then("I select a category", () => {
});
When("I choose {string} as the language for the post", (languageCode) => {
cy.get('.ds-flex-item > .ds-form-item .ds-select ')
cy.get('.contribution-form .ds-select')
.click().get('.ds-select-option')
.eq(languages.findIndex(l => l.code === languageCode)).click()
})
Then("the post shows up on the landing page at position {int}", index => {
cy.openPage("landing");
const selector = `.post-card:nth-child(${index}) > .ds-card-content`;
const selector = `.post-teaser:nth-child(${index}) > .base-card`;
cy.get(selector).should("contain", lastPost.title);
cy.get(selector).should("contain", lastPost.content);
});
@ -312,16 +312,16 @@ Then("I get redirected to {string}", route => {
});
Then("the post was saved successfully", () => {
cy.get(".ds-card-content > .ds-heading").should("contain", lastPost.title);
cy.get(".base-card > .title").should("contain", lastPost.title);
cy.get(".content").should("contain", lastPost.content);
});
Then(/^I should see only ([0-9]+) posts? on the landing page/, postCount => {
cy.get(".post-card").should("have.length", postCount);
cy.get(".post-teaser").should("have.length", postCount);
});
Then("the first post on the landing page has the title:", title => {
cy.get(".post-card:first").should("contain", title);
cy.get(".post-teaser:first").should("contain", title);
});
Then(
@ -388,7 +388,7 @@ Then("I can login successfully with password {string}", password => {
When("open the notification menu and click on the first item", () => {
cy.get(".notifications-menu").invoke('show').click(); // "invoke('show')" because of the delay for show the menu
cy.get(".notification-mention-post")
cy.get(".notification .link")
.first()
.click({
force: true
@ -424,7 +424,7 @@ When("mention {string} in the text", mention => {
Then("the notification gets marked as read", () => {
cy.get(".notifications-menu-popover .notification")
.first()
.should("have.class", "read");
.should("have.class", "--read");
});
Then("there are no notifications in the top menu", () => {
@ -510,14 +510,14 @@ Given('{string} wrote a post {string}', (_, title) => {
});
Then("the list of posts of this user is empty", () => {
cy.get(".ds-card-content").not(".post-link");
cy.get(".base-card").not(".post-link");
cy.get(".main-container").find(".ds-space.hc-empty");
});
Then("I get removed from his follower collection", () => {
cy.get(".ds-card-content").not(".post-link");
cy.get(".base-card").not(".post-link");
cy.get(".main-container").contains(
".ds-card-content",
".base-card",
"is not followed by anyone"
);
});
@ -581,7 +581,7 @@ Then("I see only one post with the title {string}", title => {
});
Then("they should not see the comment form", () => {
cy.get(".ds-card-footer").children().should('not.have.class', 'comment-form')
cy.get(".base-card").children().should('not.have.class', 'comment-form')
})
Then("they should see a text explaining why commenting is not possible", () => {
@ -600,11 +600,11 @@ Then("I {string} see {string} from the content menu in the user info box", (cond
})
Then('I should not see {string} button', button => {
cy.get('.ds-card-content .action-buttons')
cy.get('.base-card .action-buttons')
.should('have.length', 1)
})
Then('I should see the {string} button', button => {
cy.get('.ds-card-content .action-buttons .base-button')
cy.get('.base-card .action-buttons .base-button')
.should('contain', button)
})

View File

@ -35,7 +35,7 @@ Feature: Upload Teaser Image
And confirm crop
And I should be able to "change" a teaser image
And confirm crop
And the first image should be removed from the preview
And the first image should not be displayed anymore
Scenario: Add image, then delete it
When I click on the big plus icon in the bottom right corner to create post
@ -44,4 +44,4 @@ Feature: Upload Teaser Image
And I add all required fields
And I click on "Save"
Then I get redirected to ".../new-post"
And the "new" post was saved successfully without a teaser image
And the "new" post was saved successfully without a teaser image

View File

@ -55,6 +55,6 @@ Feature: Block a User
Scenario: Blocked users should not see link or button to unblock, only blocking users
Given a user has blocked me
When I visit the profile page of the annoying user
And I "should not" see "Unblock user" from the content menu in the user info box
And I should see the "Follow" button
And I should not see "Unblock user" button
And I should not see "Unblock user" button
And I "should not" see "Unblock user" from the content menu in the user info box

View File

@ -9,3 +9,13 @@ button {
font-family: inherit;
font-size: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
}

View File

@ -211,7 +211,8 @@ $letter-spacing-x-small: -0.015em;
* @presenter Opacity
*/
$opacity-soft: 0.65;
$opacity-base: 1;
$opacity-soft: 0.7;
$opacity-disabled: 0.5;
/**
@ -264,12 +265,23 @@ $size-avatar-large: 114px;
$size-button-base: 36px;
$size-button-small: 26px;
/**
* @tokens Size Images
* @presenter Spacing
*/
$size-image-max-height: 2000px;
$size-image-cropper-max-height: 600px;
$size-image-cropper-min-height: 400px;
$size-image-uploader-min-height: 200px;
/**
* @tokens Size Icons
* @presenter Spacing
*/
$size-icon-base: 16px;
$size-icon-large: 60px;
/**
* @tokens Shadow
@ -285,6 +297,12 @@ $box-shadow-active: 0 0 6px 1px rgba(20, 100, 160, 0.5);
$box-shadow-inset: inset 0 0 20px 1px rgba(0,0,0,.15);
$box-shadow-small-inset: inset 0 0 0 1px rgba(0,0,0,.05);
/**
* @tokens Effects
*/
$blur-radius: 22px;
/**
* @tokens Animation Duration
*/
@ -316,7 +334,8 @@ $z-index-page-submenu: 2500;
$z-index-page-header: 2000;
$z-index-page-sidebar: 1500;
$z-index-sticky: 100;
$z-index-post-card-link: 5;
$z-index-post-teaser-link: 5;
$z-index-surface: 1;
/**
* @tokens Media Query

View File

@ -13,6 +13,8 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1);
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
@ -141,10 +143,9 @@ hr {
}
}
.ds-card .ds-section {
.base-card > .ds-section {
padding: 0;
margin-left: -$space-base;
margin-right: -$space-base;
margin: -$space-base;
.ds-container {
padding: $space-base;

View File

@ -1,30 +1,18 @@
<template>
<div class="categories-select">
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'xx-small' }">
<div v-for="category in categories" :key="category.id">
<ds-flex-item>
<base-button
:data-test="categoryButtonsId(category.id)"
@click="toggleCategory(category.id)"
:filled="isActive(category.id)"
:disabled="isDisabled(category.id)"
:icon="category.icon"
size="small"
>
{{ $t(`contribution.category.name.${category.slug}`) }}
</base-button>
</ds-flex-item>
</div>
</ds-flex>
<p class="small-info">
{{
$t('contribution.categories.infoSelectedNoOfMaxCategories', {
chosen: selectedCount,
max: selectedMax,
})
}}
</p>
</div>
<section class="categories-select">
<base-button
v-for="category in categories"
:key="category.id"
:data-test="categoryButtonsId(category.id)"
@click="toggleCategory(category.id)"
:filled="isActive(category.id)"
:disabled="isDisabled(category.id)"
:icon="category.icon"
size="small"
>
{{ $t(`contribution.category.name.${category.slug}`) }}
</base-button>
</section>
</template>
<script>
@ -85,3 +73,15 @@ export default {
},
}
</script>
<style lang="scss">
.categories-select {
display: flex;
flex-wrap: wrap;
> .base-button {
margin-right: $space-xx-small;
margin-bottom: $space-xx-small;
}
}
</style>

View File

@ -1,230 +0,0 @@
<template>
<div v-if="(comment.deleted || comment.disabled) && !isModerator" :class="{ comment: true }">
<ds-card>
<ds-space margin-bottom="base" />
<ds-text style="padding-left: 40px; font-weight: bold;" color="soft">
<base-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
</ds-text>
<ds-space margin-bottom="base" />
</ds-card>
</div>
<div v-else :class="{ comment: true, 'disabled-content': comment.deleted || comment.disabled }">
<ds-card :id="anchor" :class="{ 'comment--target': isTarget }">
<ds-space margin-bottom="small" margin-top="small">
<user-teaser :user="author" :date-time="comment.createdAt">
<template v-slot:dateTime>
<ds-text v-if="comment.createdAt !== comment.updatedAt">
({{ $t('comment.edited') }})
</ds-text>
</template>
</user-teaser>
<client-only>
<content-menu
v-show="!openEditCommentMenu"
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
class="float-right"
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</client-only>
</ds-space>
<div v-if="openEditCommentMenu">
<comment-form
:update="true"
:post="post"
:comment="comment"
@showEditCommentMenu="editCommentMenu"
@updateComment="updateComment"
@collapse="isCollapsed = true"
/>
</div>
<div v-else>
<content-viewer :content="commentContent" class="comment-content" />
<button
v-if="isLongComment"
type="button"
class="collapse-button"
@click="isCollapsed = !isCollapsed"
>
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</button>
</div>
<ds-space margin-bottom="small" />
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click.prevent="reply"
></base-button>
</ds-card>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { COMMENT_MAX_UNTRUNCATED_LENGTH, COMMENT_TRUNCATE_TO_LENGTH } from '~/constants/comment'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations'
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
import BaseButton from '~/components/_new/generic/BaseButton/BaseButton'
export default {
mixins: [scrollToAnchor],
data() {
const anchor = `commentId-${this.comment.id}`
const isTarget = this.routeHash === `#${anchor}`
return {
anchor,
isTarget,
isCollapsed: !isTarget,
openEditCommentMenu: false,
}
},
components: {
UserTeaser,
ContentMenu,
ContentViewer,
CommentForm,
BaseButton,
},
props: {
routeHash: { type: String, default: () => '' },
post: { type: Object, default: () => ({}) },
comment: { type: Object, default: () => ({}) },
dateTime: { type: [Date, String], default: null },
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator',
}),
isLongComment() {
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
},
commentContent() {
if (this.isLongComment && this.isCollapsed) {
return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH)
}
return this.comment.content
},
displaysComment() {
return !this.unavailable || this.isModerator
},
author() {
if (this.deleted) return {}
return this.comment.author || {}
},
menuModalsData() {
return {
delete: {
titleIdent: 'delete.comment.title',
messageIdent: 'delete.comment.message',
messageParams: {
name: this.$filters.truncate(this.comment.contentExcerpt, 30),
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete.submit',
callback: this.deleteCommentCallback,
},
cancel: {
icon: 'close',
textIdent: 'delete.cancel',
callback: () => {},
},
},
},
}
},
},
methods: {
reply() {
const message = { slug: this.comment.author.slug, id: this.comment.author.id }
this.$emit('reply', message)
},
checkAnchor(anchor) {
return `#${this.anchor}` === anchor
},
isAuthor(id) {
return this.user.id === id
},
editCommentMenu(showMenu) {
this.openEditCommentMenu = showMenu
this.$emit('toggleNewCommentForm', !showMenu)
},
updateComment(comment) {
this.$emit('updateComment', comment)
},
async deleteCommentCallback() {
try {
const {
data: { DeleteComment },
} = await this.$apollo.mutate({
mutation: CommentMutations(this.$i18n).DeleteComment,
variables: { id: this.comment.id },
})
this.$toast.success(this.$t(`delete.comment.success`))
this.$emit('deleteComment', DeleteComment)
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>
<style lang="scss" scoped>
.collapse-button {
// TODO: move this to css resets
font-family: inherit;
font-size: inherit;
border: none;
background-color: transparent;
float: right;
padding: 0 16px 16px 16px;
color: $color-primary;
cursor: pointer;
}
.comment-content {
padding-left: 40px;
}
.float-right {
float: right;
}
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight {
0% {
border: 1px solid $color-primary;
}
100% {
border: 1px solid transparent;
}
}
.comment--target {
animation: highlight 4s ease;
}
</style>

View File

@ -1,5 +1,5 @@
import { config, mount } from '@vue/test-utils'
import Comment from './Comment.vue'
import CommentCard from './CommentCard.vue'
import Vuex from 'vuex'
const localVue = global.localVue
@ -8,11 +8,17 @@ localVue.directive('scrollTo', jest.fn())
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('Comment.vue', () => {
describe('CommentCard.vue', () => {
let propsData, mocks, stubs, getters, wrapper, Wrapper
beforeEach(() => {
propsData = {}
propsData = {
comment: {
id: 'comment007',
author: { id: 'some-user' },
},
postId: 'post42',
}
mocks = {
$t: jest.fn(),
$toast: {
@ -26,6 +32,7 @@ describe('Comment.vue', () => {
truncate: a => a,
removeHtml: a => a,
},
$route: { hash: '' },
$scrollTo: jest.fn(),
$apollo: {
mutate: jest.fn().mockResolvedValue({
@ -55,7 +62,7 @@ describe('Comment.vue', () => {
const store = new Vuex.Store({
getters,
})
return mount(Comment, {
return mount(CommentCard, {
store,
propsData,
mocks,

View File

@ -1,6 +1,6 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import Comment from './Comment'
import CommentCard from './CommentCard'
import helpers from '~/storybook/helpers'
helpers.init()
@ -41,14 +41,14 @@ const comment = {
__typename: 'Comment',
}
storiesOf('Comment', module)
storiesOf('CommentCard', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('Basic comment', () => ({
components: { Comment },
components: { CommentCard },
store: helpers.store,
data: () => ({
comment,
}),
template: `<comment :key="comment.id" :comment="comment" />`,
template: `<comment-card :key="comment.id" :comment="comment" />`,
}))

View File

@ -0,0 +1,216 @@
<template>
<base-card v-if="isUnavailable" class="comment-card">
<p>
<base-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
</p>
</base-card>
<base-card v-else :class="commentClass" :id="anchor">
<header class="header">
<user-teaser :user="comment.author" :date-time="comment.createdAt">
<template v-if="wasEdited" #dateTime>
<span>({{ $t('comment.edited') }})</span>
</template>
</user-teaser>
<client-only>
<content-menu
v-show="!editingComment"
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
:is-owner="user.id === comment.author.id"
@editComment="editComment(true)"
/>
</client-only>
</header>
<comment-form
v-if="editingComment"
:update="true"
:postId="postId"
:comment="comment"
@finishEditing="editComment(false)"
@updateComment="updateComment"
@collapse="isCollapsed = true"
/>
<template v-else>
<content-viewer :content="commentContent" class="content" />
<base-button v-if="hasLongContent" size="small" ghost @click="isCollapsed = !isCollapsed">
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</base-button>
</template>
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click="reply"
/>
</base-card>
</template>
<script>
import { mapGetters } from 'vuex'
import { COMMENT_MAX_UNTRUNCATED_LENGTH, COMMENT_TRUNCATE_TO_LENGTH } from '~/constants/comment'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations'
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
export default {
components: {
UserTeaser,
ContentMenu,
ContentViewer,
CommentForm,
},
mixins: [scrollToAnchor],
data() {
const anchor = `commentId-${this.comment.id}`
const isTarget = this.$route.hash === `#${anchor}`
return {
anchor,
isTarget,
isCollapsed: !isTarget,
editingComment: false,
}
},
props: {
comment: {
type: Object,
required: true,
},
postId: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator',
}),
hasLongContent() {
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
},
isUnavailable() {
return (this.comment.deleted || this.comment.disabled) && !this.isModerator
},
wasEdited() {
return this.comment.createdAt !== this.comment.updatedAt
},
commentClass() {
let commentClass = 'comment-card'
if (this.comment.deleted || this.comment.disabled) commentClass += ' disabled-content'
if (this.isTarget) commentClass += ' --target'
return commentClass
},
commentContent() {
if (this.hasLongContent && this.isCollapsed) {
return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH)
}
return this.comment.content
},
menuModalsData() {
return {
delete: {
titleIdent: 'delete.comment.title',
messageIdent: 'delete.comment.message',
messageParams: {
name: this.$filters.truncate(this.comment.contentExcerpt, 30),
},
buttons: {
confirm: {
danger: true,
icon: 'trash',
textIdent: 'delete.submit',
callback: this.deleteCommentCallback,
},
cancel: {
icon: 'close',
textIdent: 'delete.cancel',
callback: () => {},
},
},
},
}
},
},
methods: {
checkAnchor(anchor) {
return `#${this.anchor}` === anchor
},
reply() {
const message = { slug: this.comment.author.slug, id: this.comment.author.id }
this.$emit('reply', message)
},
editComment(editing) {
this.editingComment = editing
this.$emit('toggleNewCommentForm', !editing)
},
updateComment(comment) {
this.$emit('updateComment', comment)
},
async deleteCommentCallback() {
try {
const {
data: { DeleteComment },
} = await this.$apollo.mutate({
mutation: CommentMutations(this.$i18n).DeleteComment,
variables: { id: this.comment.id },
})
this.$toast.success(this.$t(`delete.comment.success`))
this.$emit('deleteComment', DeleteComment)
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>
<style lang="scss">
.comment-card {
display: flex;
flex-direction: column;
margin-bottom: $space-small;
&.--target {
animation: highlight 4s ease;
}
> .header {
display: flex;
justify-content: space-between;
margin-bottom: $space-small;
}
> .base-button {
align-self: flex-end;
}
}
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight {
0% {
border: $border-size-base solid $color-primary;
}
100% {
border: $border-size-base solid transparent;
}
}
</style>

View File

@ -153,10 +153,10 @@ describe('CommentForm.vue', () => {
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
it('emits `showEditCommentMenu` event', async () => {
it('emits `finishEditing` event', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[false]])
expect(wrapper.emitted('finishEditing')).toBeTruthy()
})
})
@ -167,10 +167,10 @@ describe('CommentForm.vue', () => {
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
it('emits `showEditCommentMenu` event', async () => {
it('emits `finishEditing` event', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('[data-test="cancel-button"]').trigger('submit')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[false]])
expect(wrapper.emitted('finishEditing')).toBeTruthy()
})
})

View File

@ -1,8 +1,7 @@
<template>
<ds-form v-model="form" @submit="handleSubmit" class="comment-form">
<template slot-scope="{ errors }">
<ds-card>
<!-- with client-only the content is not shown -->
<base-card>
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<div class="buttons">
<base-button
@ -17,7 +16,7 @@
{{ $t('post.comment.submit') }}
</base-button>
</div>
</ds-card>
</base-card>
</template>
</ds-form>
</template>
@ -72,7 +71,7 @@ export default {
this.$refs.editor.clear()
},
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
this.$emit('finishEditing')
},
handleCancel() {
if (!this.update) {
@ -146,10 +145,13 @@ export default {
<style lang="scss">
.comment-form {
.editor {
margin-bottom: $space-small;
}
.buttons {
display: flex;
justify-content: flex-end;
margin: $space-small 0;
> .base-button {
margin-left: $space-x-small;

View File

@ -1,6 +1,6 @@
import { config, mount } from '@vue/test-utils'
import CommentList from './CommentList'
import Comment from '~/components/Comment/Comment'
import CommentCard from '~/components/CommentCard/CommentCard'
import Vuex from 'vuex'
import Vue from 'vue'
@ -20,9 +20,14 @@ describe('CommentList.vue', () => {
beforeEach(() => {
propsData = {
post: {
id: 1,
id: 'post42',
comments: [
{ id: 'comment134', contentExcerpt: 'this is a comment', content: 'this is a comment' },
{
id: 'comment134',
contentExcerpt: 'this is a comment',
content: 'this is a comment',
author: { id: 'some-user' },
},
],
},
}
@ -41,6 +46,7 @@ describe('CommentList.vue', () => {
removeHtml: a => a,
},
$scrollTo: jest.fn(),
$route: { hash: '' },
$apollo: {
queries: {
Post: {
@ -73,12 +79,6 @@ describe('CommentList.vue', () => {
beforeEach(jest.useFakeTimers)
describe('$route.hash !== `#comments`', () => {
beforeEach(() => {
mocks.$route = {
hash: '',
}
})
it('skips $scrollTo', () => {
wrapper = Wrapper()
jest.runAllTimers()
@ -107,7 +107,7 @@ describe('CommentList.vue', () => {
})
it('Comment emitted reply()', () => {
wrapper.find(Comment).vm.$emit('reply', {
wrapper.find(CommentCard).vm.$emit('reply', {
id: 'commentAuthorId',
slug: 'ogerly',
})

View File

@ -4,15 +4,12 @@
<counter-icon icon="comments" :count="postComments.length" />
{{ $t('common.comment', null, 0) }}
</h3>
<ds-space margin-bottom="large" />
<div id="comments" class="comments">
<comment
<div v-if="postComments" id="comments" class="comments">
<comment-card
v-for="comment in postComments"
:key="comment.id"
:comment="comment"
:post="post"
:routeHash="routeHash"
class="comment-tag"
:postId="post.id"
@deleteComment="updateCommentList"
@updateComment="updateCommentList"
@toggleNewCommentForm="toggleNewCommentForm"
@ -23,18 +20,20 @@
</template>
<script>
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Comment from '~/components/Comment/Comment'
import CommentCard from '~/components/CommentCard/CommentCard'
import scrollToAnchor from '~/mixins/scrollToAnchor'
export default {
mixins: [scrollToAnchor],
components: {
CounterIcon,
Comment,
CommentCard,
},
props: {
routeHash: { type: String, default: () => '' },
post: { type: Object, default: () => {} },
post: {
type: Object,
required: true,
},
},
computed: {
postComments() {

View File

@ -154,7 +154,7 @@ describe('ContentMenu.vue', () => {
.filter(item => item.text() === 'comment.menu.edit')
.at(0)
.trigger('click')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[true]])
expect(wrapper.emitted('editComment')).toBeTruthy()
})
it('delete the comment', () => {
wrapper

View File

@ -8,7 +8,7 @@
size="small"
circle
ghost
@click="toggleMenu"
@click.prevent="toggleMenu()"
/>
</slot>
</template>
@ -104,7 +104,7 @@ export default {
routes.push({
label: this.$t(`comment.menu.edit`),
callback: () => {
this.$emit('showEditCommentMenu', true)
this.$emit('editComment')
},
icon: 'edit',
})

View File

@ -6,7 +6,7 @@ import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import TeaserImage from '~/components/TeaserImage/TeaserImage'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
@ -182,7 +182,7 @@ describe('ContributionForm.vue', () => {
})
it('has no more than three categories', async () => {
wrapper.vm.form.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
wrapper.vm.formData.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
await Vue.nextTick()
wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
@ -233,10 +233,13 @@ describe('ContributionForm.vue', () => {
})
it('supports adding a teaser image', async () => {
const spy = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {})
expectedParams.variables.imageUpload = imageUpload
wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload)
wrapper.find(ImageUploader).vm.$emit('addHeroImage', imageUpload)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
expect(spy).toHaveBeenCalledWith(imageUpload)
spy.mockReset()
})
it('content is valid with just a link', async () => {
@ -320,20 +323,12 @@ describe('ContributionForm.vue', () => {
wrapper = Wrapper()
})
it('sets id equal to contribution id', () => {
expect(wrapper.vm.id).toEqual(propsData.contribution.id)
})
it('sets slug equal to contribution slug', () => {
expect(wrapper.vm.slug).toEqual(propsData.contribution.slug)
})
it('sets title equal to contribution title', () => {
expect(wrapper.vm.form.title).toEqual(propsData.contribution.title)
expect(wrapper.vm.formData.title).toEqual(propsData.contribution.title)
})
it('sets content equal to contribution content', () => {
expect(wrapper.vm.form.content).toEqual(propsData.contribution.content)
expect(wrapper.vm.formData.content).toEqual(propsData.contribution.content)
})
describe('valid update', () => {
@ -362,6 +357,7 @@ describe('ContributionForm.vue', () => {
image,
imageUpload: null,
imageAspectRatio: 1,
imageBlurred: false,
},
}
})
@ -387,9 +383,10 @@ describe('ContributionForm.vue', () => {
it('supports deleting a teaser image', async () => {
expectedParams.variables.image = null
expectedParams.variables.imageAspectRatio = null
propsData.contribution.image = '/uploads/someimage.png'
wrapper = Wrapper()
wrapper.find('.contribution-form .delete-image').trigger('click')
wrapper.find('[data-test="delete-button"]').trigger('click')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})

View File

@ -2,115 +2,75 @@
<ds-form
class="contribution-form"
ref="contributionForm"
v-model="form"
v-model="formData"
:schema="formSchema"
@submit="submit"
>
<template slot-scope="{ errors }">
<base-button
v-if="showDeleteImageButton"
class="delete-image"
icon="close"
size="small"
circle
danger
filled
@click.prevent="deleteImage"
/>
<hc-teaser-image
:contribution="contribution"
:class="{ '--blur-image': form.blurImage }"
@addTeaserImage="addTeaserImage"
@addImageAspectRatio="addImageAspectRatio"
@cropInProgress="cropInProgress"
>
<img
v-if="contribution"
class="contribution-image"
:src="contribution.image | proxyApiUrl"
/>
</hc-teaser-image>
<ds-card>
<div class="blur-toggle">
<base-card>
<template #heroImage>
<img
v-if="formData.image"
:src="formData.image | proxyApiUrl"
:class="['image', formData.imageBlurred && '--blur-image']"
/>
<image-uploader
:hasImage="!!formData.image"
:class="[formData.imageBlurred && '--blur-image']"
@addHeroImage="addHeroImage"
@addImageAspectRatio="addImageAspectRatio"
/>
</template>
<div v-if="formData.image" class="blur-toggle">
<label for="blur-img">{{ $t('contribution.inappropriatePicture') }}</label>
<input type="checkbox" id="blur-img" v-model="form.blurImage" />
<p>
<a
href="https://support.human-connection.org/kb/faq.php?id=113"
target="_blank"
class="link"
>
{{ $t('contribution.inappropriatePictureText') }}
<ds-icon name="question-circle" />
</a>
</p>
<input type="checkbox" id="blur-img" v-model="formData.imageBlurred" />
<a
href="https://support.human-connection.org/kb/faq.php?id=113"
target="_blank"
class="link"
>
{{ $t('contribution.inappropriatePictureText') }}
<base-icon name="question-circle" />
</a>
</div>
<ds-space />
<client-only>
<user-teaser :user="currentUser" />
</client-only>
<ds-space />
<ds-input
model="title"
class="post-title"
:placeholder="$t('contribution.title')"
name="title"
autofocus
size="large"
/>
<ds-text align="right">
<ds-chip v-if="errors && errors.title" color="danger" size="base">
{{ form.title.length }}/{{ formSchema.title.max }}
<ds-icon name="warning"></ds-icon>
</ds-chip>
<ds-chip v-else size="base">{{ form.title.length }}/{{ formSchema.title.max }}</ds-chip>
</ds-text>
<ds-chip size="base" :color="errors && errors.title && 'danger'">
{{ formData.title.length }}/{{ formSchema.title.max }}
<base-icon v-if="errors && errors.title" name="warning" />
</ds-chip>
<hc-editor
:users="users"
:value="form.content"
:value="formData.content"
:hashtags="hashtags"
@input="updateEditorContent"
/>
<ds-text align="right">
<ds-chip v-if="errors && errors.content" color="danger" size="base">
{{ contentLength }}
<ds-icon name="warning"></ds-icon>
</ds-chip>
<ds-chip v-else size="base">
{{ contentLength }}
</ds-chip>
</ds-text>
<ds-space margin-bottom="small" />
<hc-categories-select model="categoryIds" :existingCategoryIds="form.categoryIds" />
<ds-text align="right">
<ds-chip v-if="errors && errors.categoryIds" color="danger" size="base">
{{ form.categoryIds.length }} / 3
<ds-icon name="warning"></ds-icon>
</ds-chip>
<ds-chip v-else size="base">{{ form.categoryIds.length }} / 3</ds-chip>
</ds-text>
<ds-flex class="contribution-form-footer">
<ds-flex-item :width="{ lg: '50%', md: '50%', sm: '100%' }" />
<ds-flex-item>
<ds-space margin-bottom="small" />
<ds-select
model="language"
:options="languageOptions"
icon="globe"
:placeholder="$t('contribution.languageSelectText')"
:label="$t('contribution.languageSelectLabel')"
/>
</ds-flex-item>
</ds-flex>
<ds-text align="right">
<ds-chip v-if="errors && errors.language" size="base" color="danger">
<ds-icon name="warning"></ds-icon>
</ds-chip>
</ds-text>
<ds-space />
<div slot="footer" style="text-align: right">
<ds-chip size="base" :color="errors && errors.content && 'danger'">
{{ contentLength }}
<base-icon v-if="errors && errors.content" name="warning" />
</ds-chip>
<categories-select model="categoryIds" :existingCategoryIds="formData.categoryIds" />
<ds-chip size="base" :color="errors && errors.categoryIds && 'danger'">
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<ds-select
model="language"
icon="globe"
class="select-field"
:options="languageOptions"
:placeholder="$t('contribution.languageSelectText')"
:label="$t('contribution.languageSelectLabel')"
/>
<ds-chip v-if="errors && errors.language" size="base" color="danger">
<base-icon name="warning" />
</ds-chip>
<div class="buttons">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger>
{{ $t('actions.cancel') }}
</base-button>
@ -118,8 +78,7 @@
{{ $t('actions.save') }}
</base-button>
</div>
<ds-space margin-bottom="large" />
</ds-card>
</base-card>
</template>
</ds-form>
</template>
@ -131,127 +90,95 @@ import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor'
import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js'
import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import HcTeaserImage from '~/components/TeaserImage/TeaserImage'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
export default {
components: {
HcEditor,
HcCategoriesSelect,
HcTeaserImage,
UserTeaser,
CategoriesSelect,
ImageUploader,
},
props: {
contribution: { type: Object, default: () => {} },
contribution: {
type: Object,
default: () => ({}),
},
},
data() {
const {
title,
content,
image,
imageAspectRatio,
imageBlurred,
language,
categories,
} = this.contribution
const languageOptions = orderBy(locales, 'name').map(locale => {
return { label: locale.name, value: locale.code }
})
const formDefaults = {
title: '',
content: '',
teaserImage: null,
imageAspectRatio: null,
image: null,
language: null,
categoryIds: [],
blurImage: false,
}
let id = null
let slug = null
const form = { ...formDefaults }
if (this.contribution && this.contribution.id) {
id = this.contribution.id
slug = this.contribution.slug
form.title = this.contribution.title
form.content = this.contribution.content
form.image = this.contribution.image
form.language =
this.contribution && this.contribution.language
? languageOptions.find(o => this.contribution.language === o.value)
: null
form.categoryIds = this.categoryIds(this.contribution.categories)
form.imageAspectRatio = this.contribution.imageAspectRatio
form.blurImage = this.contribution.imageBlurred
}
return {
form,
formData: {
title: title || '',
content: content || '',
image: image || null,
imageAspectRatio: imageAspectRatio || null,
imageBlurred: imageBlurred || false,
language: languageOptions.find(option => option.value === language) || null,
categoryIds: categories ? categories.map(category => category.id) : [],
},
formSchema: {
title: { required: true, min: 3, max: 100 },
content: { required: true },
categoryIds: {
type: 'array',
required: true,
validator: (rule, value) => {
const errors = []
if (!(value && value.length >= 1 && value.length <= 3)) {
errors.push(new Error(this.$t('common.validations.categories')))
validator: (_, value = []) => {
if (value.length === 0 || value.length > 3) {
return [new Error(this.$t('common.validations.categories'))]
}
return errors
return []
},
},
language: { required: true },
blurImage: { required: false },
imageBlurred: { required: false },
},
languageOptions,
id,
slug,
loading: false,
users: [],
contentMin: 3,
hashtags: [],
elem: null,
isCropInProgress: null,
imageUpload: null,
}
},
computed: {
contentLength() {
return this.$filters.removeHtml(this.form.content).length
},
...mapGetters({
currentUser: 'auth/user',
}),
showDeleteImageButton() {
return this.contribution && this.contribution.image && !this.isCropInProgress
contentLength() {
return this.$filters.removeHtml(this.formData.content).length
},
},
methods: {
submit() {
const {
language: { value: language },
title,
content,
image,
teaserImage,
imageAspectRatio,
categoryIds,
blurImage,
} = this.form
this.loading = true
this.$apollo
.mutate({
mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
variables: {
id: this.id,
title,
content,
categoryIds,
language,
image,
imageUpload: teaserImage,
imageBlurred: blurImage,
imageAspectRatio,
...this.formData,
id: this.contribution.id || null,
language: this.formData.language.value,
image: this.imageUpload ? null : this.formData.image,
imageUpload: this.imageUpload,
},
})
.then(({ data }) => {
this.loading = false
this.$toast.success(this.$t('contribution.success'))
const result = data[this.id ? 'UpdatePost' : 'CreatePost']
const result = data[this.contribution.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({
name: 'post-id-slug',
@ -266,22 +193,19 @@ export default {
updateEditorContent(value) {
this.$refs.contributionForm.update('content', value)
},
addTeaserImage(file) {
this.form.teaserImage = file
addHeroImage(file) {
this.formData.image = null
if (file) {
const reader = new FileReader()
reader.onload = ({ target }) => {
this.formData.image = target.result
}
this.imageUpload = file
reader.readAsDataURL(file)
}
},
addImageAspectRatio(aspectRatio) {
this.form.imageAspectRatio = aspectRatio
},
categoryIds(categories) {
return categories.map(c => c.id)
},
deleteImage() {
this.contribution.image = null
this.form.image = null
this.form.teaserImage = null
},
cropInProgress(boolean) {
this.isCropInProgress = boolean
this.formData.imageAspectRatio = aspectRatio
},
},
apollo: {
@ -319,41 +243,48 @@ export default {
</script>
<style lang="scss">
.contribution-form {
.ds-card-image.--blur-image img {
filter: blur(32px);
.contribution-form > .base-card {
display: flex;
flex-direction: column;
> .hero-image {
position: relative;
> .image {
max-height: $size-image-max-height;
}
}
.image.--blur-image {
filter: blur($blur-radius);
}
> .ds-form-item {
margin: 0;
}
> .ds-chip {
align-self: flex-end;
margin: $space-xx-small 0 $space-base;
cursor: default;
}
> .select-field {
align-self: flex-end;
}
> .buttons {
align-self: flex-end;
margin-top: $space-base;
}
.blur-toggle {
text-align: right;
margin-bottom: $space-base;
> .link {
display: block;
}
}
.ds-chip {
cursor: default;
}
.post-title {
margin-top: $space-x-small;
margin-bottom: $space-xx-small;
input {
border: 0;
font-size: $font-size-x-large;
font-weight: bold;
padding-left: 0;
padding-right: 0;
}
}
}
.delete-image {
right: 10px;
position: relative;
z-index: 1;
float: right;
top: $space-large;
}
</style>

View File

@ -88,7 +88,7 @@ describe('DeleteData.vue', () => {
describe('calls the delete user mutation', () => {
beforeEach(() => {
enableDeletionInput = wrapper.find('.enable-deletion-input input')
enableDeletionInput = wrapper.find('.ds-input')
enableDeletionInput.setValue(deleteAccountName)
deleteAccountBtn = wrapper.find('[data-test="delete-button"]')
})
@ -107,7 +107,7 @@ describe('DeleteData.vue', () => {
it("deletes a user's posts if requested", () => {
mocks.$t.mockImplementation(() => deleteContributionsMessage)
enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0)
enableContributionDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(0)
enableContributionDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -122,7 +122,7 @@ describe('DeleteData.vue', () => {
it("deletes a user's comments if requested", () => {
mocks.$t.mockImplementation(() => deleteCommentsMessage)
enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1)
enableCommentDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(1)
enableCommentDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -137,10 +137,10 @@ describe('DeleteData.vue', () => {
it("deletes a user's posts and comments if requested", () => {
mocks.$t.mockImplementation(() => deleteContributionsMessage)
enableContributionDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(0)
enableContributionDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(0)
enableContributionDeletionCheckbox.trigger('click')
mocks.$t.mockImplementation(() => deleteCommentsMessage)
enableCommentDeletionCheckbox = wrapper.findAll('.checkbox-container input').at(1)
enableCommentDeletionCheckbox = wrapper.findAll('input[type="checkbox"]').at(1)
enableCommentDeletionCheckbox.trigger('click')
deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
@ -166,7 +166,7 @@ describe('DeleteData.vue', () => {
describe('error handling', () => {
it('shows an error toaster when the mutation rejects', async () => {
enableDeletionInput = wrapper.find('.enable-deletion-input input')
enableDeletionInput = wrapper.find('.ds-input')
enableDeletionInput.setValue(deleteAccountName)
await Vue.nextTick()
deleteAccountBtn = wrapper.find('[data-test="delete-button"]')

View File

@ -1,80 +1,46 @@
<template>
<div>
<ds-card hover>
<ds-space />
<ds-container>
<ds-flex>
<ds-flex-item :width="{ base: '22%', sm: '12%', md: '12%', lg: '8%' }">
<base-icon name="warning" class="delete-warning-icon" />
</ds-flex-item>
<ds-flex-item :width="{ base: '78%', sm: '88%', md: '88%', lg: '92%' }">
<ds-heading>{{ $t('settings.deleteUserAccount.name') }}</ds-heading>
</ds-flex-item>
<ds-space />
<ds-heading tag="h4">
{{ $t('settings.deleteUserAccount.accountDescription') }}
</ds-heading>
</ds-flex>
</ds-container>
<ds-space />
<ds-container>
<transition name="slide-up">
<div v-if="deleteEnabled">
<label v-if="currentUser.contributionsCount" class="checkbox-container">
<input type="checkbox" v-model="deleteContributions" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.contributionsCount', {
count: currentUser.contributionsCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<label v-if="currentUser.commentedCount" class="checkbox-container">
<input type="checkbox" v-model="deleteComments" />
<span class="checkmark"></span>
{{
$t('settings.deleteUserAccount.commentedCount', {
count: currentUser.commentedCount,
})
}}
</label>
<ds-space margin-bottom="small" />
<ds-section id="delete-user-account-warning">
<div v-html="$t('settings.deleteUserAccount.accountWarning')"></div>
</ds-section>
</div>
</transition>
</ds-container>
<template slot="footer" class="delete-data-footer">
<ds-container>
<div
class="delete-input-label"
v-html="$t('settings.deleteUserAccount.pleaseConfirm', { confirm: currentUser.name })"
></div>
<ds-space margin-bottom="xx-small" />
<ds-flex :gutter="{ base: 'xx-small', md: 'small', lg: 'large' }">
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1.75 }">
<ds-input v-model="enableDeletionValue" class="enable-deletion-input" />
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '100%', lg: 1 }">
<base-button
icon="trash"
danger
filled
:disabled="!deleteEnabled"
data-test="delete-button"
@click="handleSubmit"
>
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
</ds-flex-item>
</ds-flex>
</ds-container>
</template>
</ds-card>
</div>
<base-card class="delete-data">
<h2 class="title">
<base-icon name="warning" />
{{ $t('settings.deleteUserAccount.name') }}
</h2>
<label>
{{ $t('settings.deleteUserAccount.pleaseConfirm', { confirm: currentUser.name }) }}
</label>
<ds-input v-model="enableDeletionValue" />
<p class="notice">{{ $t('settings.deleteUserAccount.accountDescription') }}</p>
<label v-if="currentUser.contributionsCount" class="checkbox">
<input type="checkbox" v-model="deleteContributions" />
{{
$t('settings.deleteUserAccount.contributionsCount', {
count: currentUser.contributionsCount,
})
}}
</label>
<label v-if="currentUser.commentedCount" class="checkbox">
<input type="checkbox" v-model="deleteComments" />
{{
$t('settings.deleteUserAccount.commentedCount', {
count: currentUser.commentedCount,
})
}}
</label>
<section v-if="deleteEnabled" class="warning">
<p>{{ $t('settings.deleteUserAccount.accountWarning') }}</p>
</section>
<base-button
icon="trash"
danger
filled
:disabled="!deleteEnabled"
data-test="delete-button"
@click="handleSubmit"
>
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
</base-card>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import gql from 'graphql-tag'
@ -131,96 +97,47 @@ export default {
},
}
</script>
<style lang="scss">
.delete-warning-icon {
color: $color-danger;
font-size: $font-size-xxx-large;
}
.delete-data {
display: flex;
flex-direction: column;
.checkbox-container {
display: block;
position: relative;
padding-left: 35px;
cursor: pointer;
font-size: $font-size-large;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
> .title > .base-icon {
color: $color-danger;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
> .ds-form-item {
align-self: flex-start;
margin-top: $space-xxx-small;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
border: 2px solid $background-color-inverse-softer;
background-color: $background-color-base;
border-radius: $border-radius-x-large;
}
> .notice {
font-weight: $font-weight-bold;
margin-bottom: $space-small;
}
.checkbox-container:hover input ~ .checkmark {
background-color: $background-color-softest;
}
> .checkbox {
margin-left: $space-base;
margin-bottom: $space-x-small;
.checkbox-container input:checked ~ .checkmark {
background-color: $background-color-danger-active;
}
&:last-of-type {
margin-bottom: $space-small;
}
}
.checkmark:after {
content: '';
position: absolute;
display: none;
}
> .warning {
padding: $space-large;
margin-bottom: $space-small;
border-radius: $border-radius-base;
.checkbox-container input:checked ~ .checkmark:after {
display: block;
}
color: $color-danger;
background-color: $color-danger-inverse;
border-left: 4px solid $color-danger;
}
.checkbox-container .checkmark:after {
left: 6px;
top: 3px;
width: 5px;
height: 10px;
border: solid $background-color-base;
border-width: 0 $border-size-large $border-size-large 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.enable-deletion-input input:focus {
border-color: $border-color-danger;
}
.delete-input-label {
font-size: $font-size-base;
}
b.is-danger {
color: $text-color-danger;
}
.delete-data-footer {
border-top: $border-size-base solid $border-color-softest;
background-color: $background-color-danger-inverse;
}
#delete-user-account-warning {
background-color: $background-color-danger-inverse;
border-left: $border-size-x-large solid $background-color-danger-active;
color: $text-color-danger;
margin-left: 0px;
margin-right: 0px;
border-radius: $border-radius-x-large;
> .base-button {
align-self: flex-start;
}
}
</style>

View File

@ -40,9 +40,9 @@ storiesOf('Editor', module)
return {
components: { ctx },
template: `
<ds-card style="width: 50%; min-width: 500px; margin: 0 auto;">
<base-card style="width: 50%; min-width: 500px; margin: 0 auto;">
<ctx />
</ds-card>
</base-card>
`,
}
})

View File

@ -89,7 +89,7 @@ export default {
}
&.hint {
opacity: 0.7;
opacity: $opacity-soft;
pointer-events: none;
}
}

View File

@ -12,10 +12,10 @@ describe('FilterMenu.vue', () => {
mocks = { $t: () => {} }
})
describe('given a user', () => {
describe('given a hashtag', () => {
beforeEach(() => {
propsData = {
hashtag: null,
hashtag: 'Frieden',
}
})
@ -27,19 +27,14 @@ describe('FilterMenu.vue', () => {
wrapper = Wrapper()
})
it('does not render a card if there are no hashtags', () => {
expect(wrapper.is('.ds-card')).toBe(true)
})
it('renders a card if there are hashtags', () => {
propsData.hashtag = 'Frieden'
it('renders a card', () => {
wrapper = Wrapper()
expect(wrapper.is('.ds-card')).toBe(true)
expect(wrapper.is('.base-card')).toBe(true)
})
describe('click "clear-search-button" button', () => {
describe('click clear search button', () => {
it('emits clearSearch', () => {
wrapper.find('[name="clear-search-button"]').trigger('click')
wrapper.find('.base-button').trigger('click')
expect(wrapper.emitted().clearSearch).toHaveLength(1)
})
})

View File

@ -1,32 +1,22 @@
<template>
<ds-card class="filter-menu-card">
<ds-flex class="filter-menu-content">
<ds-flex-item>
<ds-heading size="h3">{{ $t('filter-menu.hashtag-search', { hashtag }) }}</ds-heading>
</ds-flex-item>
<ds-flex-item>
<div class="filter-menu-buttons">
<base-button
name="clear-search-button"
icon="close"
circle
@click="clearSearch"
v-tooltip="{
content: this.$t('filter-menu.clearSearch'),
placement: 'left',
delay: { show: 500 },
}"
/>
</div>
</ds-flex-item>
</ds-flex>
</ds-card>
<base-card class="filter-menu">
<h2>{{ $t('filter-menu.hashtag-search', { hashtag }) }}</h2>
<base-button
icon="close"
circle
:title="this.$t('filter-menu.clearSearch')"
@click="clearSearch"
/>
</base-card>
</template>
<script>
export default {
props: {
hashtag: { type: String, default: null },
hashtag: {
type: String,
required: true,
},
},
methods: {
clearSearch() {
@ -37,21 +27,10 @@ export default {
</script>
<style lang="scss">
.filter-menu-card {
background-color: $background-color-soft;
}
.filter-menu-content {
height: 100%;
align-items: center;
}
.filter-menu-title {
.filter-menu.base-card {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-menu-buttons {
float: right;
padding: $space-x-small $space-base;
}
</style>

View File

@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import TeaserImage from './TeaserImage.vue'
import ImageUploader from './ImageUploader.vue'
const localVue = global.localVue
describe('TeaserImage.vue', () => {
describe('ImageUploader.vue', () => {
let wrapper
let mocks
@ -17,7 +17,7 @@ describe('TeaserImage.vue', () => {
})
describe('mount', () => {
const Wrapper = () => {
return mount(TeaserImage, { mocks, localVue })
return mount(ImageUploader, { mocks, localVue })
}
beforeEach(() => {
wrapper = Wrapper()
@ -28,21 +28,10 @@ describe('TeaserImage.vue', () => {
const message = 'File upload failed'
const fileError = { status: 'error' }
it('defaults to error false', () => {
expect(wrapper.vm.error).toEqual(false)
})
it('shows an error toaster when verror is called', () => {
wrapper.vm.verror(fileError, message)
wrapper.vm.onDropzoneError(fileError, message)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message)
})
it('changes error status from false to true to false', () => {
wrapper.vm.verror(fileError, message)
expect(wrapper.vm.error).toEqual(true)
jest.runAllTimers()
expect(wrapper.vm.error).toEqual(false)
})
})
})
})

View File

@ -0,0 +1,200 @@
<template>
<div class="image-uploader">
<vue-dropzone
v-show="!showCropper"
id="postdropzone"
:options="dropzoneOptions"
:use-custom-slot="true"
@vdropzone-error="onDropzoneError"
@vdropzone-file-added="initCropper"
>
<loading-spinner v-if="isLoadingImage" />
<base-icon v-else name="image" />
<base-button
v-if="hasImage"
icon="trash"
circle
danger
filled
data-test="delete-button"
:title="$t('actions.delete')"
@click.stop="deleteImage"
/>
</vue-dropzone>
<div v-show="showCropper" class="crop-overlay">
<img id="cropping-image" />
<base-button class="crop-confirm" filled @click="cropImage">
{{ $t('contribution.teaserImage.cropperConfirm') }}
</base-button>
<base-button
class="crop-cancel"
icon="close"
size="small"
circle
danger
filled
@click="closeCropper"
/>
</div>
</div>
</template>
<script>
import VueDropzone from 'nuxt-dropzone'
import Cropper from 'cropperjs'
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
import 'cropperjs/dist/cropper.css'
export default {
components: {
LoadingSpinner,
VueDropzone,
},
props: {
hasImage: {
type: Boolean,
default: false,
},
},
data() {
return {
dropzoneOptions: {
url: () => '',
maxFilesize: 5.0,
previewTemplate: '<span class="no-preview" />',
},
cropper: null,
file: null,
showCropper: false,
isLoadingImage: false,
}
},
methods: {
onDropzoneError(file, message) {
this.$toast.error(file.status, message)
},
initCropper(file) {
this.showCropper = true
this.file = file
const imageElement = document.querySelector('#cropping-image')
imageElement.src = URL.createObjectURL(file)
this.cropper = new Cropper(imageElement, { zoomable: false, autoCropArea: 0.9 })
},
cropImage() {
this.isLoadingImage = true
const onCropComplete = (aspectRatio, imageFile) => {
this.$emit('addImageAspectRatio', aspectRatio)
this.$emit('addHeroImage', imageFile)
this.$nextTick((this.isLoadingImage = false))
this.closeCropper()
}
if (this.file.type === 'image/jpeg') {
const canvas = this.cropper.getCroppedCanvas()
canvas.toBlob(blob => {
const imageAspectRatio = canvas.width / canvas.height
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
onCropComplete(imageAspectRatio, croppedImageFile)
}, 'image/jpeg')
} else {
// TODO: use cropped file instead of original file
const imageAspectRatio = this.file.width / this.file.height || 1.0
onCropComplete(imageAspectRatio, this.file)
}
},
closeCropper() {
this.showCropper = false
this.cropper.destroy()
},
deleteImage() {
this.$emit('addHeroImage', null)
this.$emit('addImageAspectRatio', null)
},
},
}
</script>
<style lang="scss">
.image-uploader {
position: relative;
min-height: $size-image-uploader-min-height;
cursor: pointer;
.image + & {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&:only-child {
background-color: $color-neutral-85;
}
&:disabled {
pointer-events: none;
}
> .crop-overlay {
width: 100%;
height: 100%;
min-height: $size-image-cropper-min-height;
max-height: $size-image-cropper-max-height;
font-size: $font-size-base;
> .img {
display: block;
max-width: 100%;
}
> .crop-confirm {
position: absolute;
left: $space-x-small;
top: $space-x-small;
z-index: $z-index-surface;
}
> .crop-cancel {
position: absolute;
right: $space-x-small;
top: $space-x-small;
z-index: $z-index-surface;
}
}
.dz-message {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
z-index: $z-index-surface;
&:hover {
> .base-icon {
opacity: $opacity-base;
}
}
> .base-icon {
position: absolute;
padding: $space-small;
border-radius: 100%;
border: $border-size-base dashed $color-neutral-20;
background-color: $color-neutral-95;
font-size: $size-icon-large;
opacity: $opacity-soft;
}
> .base-button {
position: absolute;
top: $space-small;
right: $space-small;
z-index: $z-index-surface;
}
}
}
</style>

View File

@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils'
import { config, mount } from '@vue/test-utils'
import LocaleSwitch from './LocaleSwitch.vue'
import Vuex from 'vuex'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
describe('LocaleSwitch.vue', () => {
let wrapper, mocks, computed, deutschLanguageItem, getters

View File

@ -1,35 +1,37 @@
<template>
<dropdown ref="menu" :placement="placement" :offset="offset">
<a
slot="default"
slot-scope="{ toggleMenu }"
class="locale-menu"
href="#"
@click.prevent="toggleMenu()"
>
<base-icon name="globe" />
<span class="label">{{ current.code.toUpperCase() }}</span>
<base-icon class="dropdown-arrow" name="angle-down" />
</a>
<ds-menu
slot="popover"
slot-scope="{ toggleMenu }"
class="locale-menu-popover"
:matcher="matcher"
:routes="routes"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
class="locale-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="changeLanguage(item.route.path, toggleMenu)"
<client-only>
<dropdown ref="menu" :placement="placement" :offset="offset">
<a
slot="default"
slot-scope="{ toggleMenu }"
class="locale-menu"
href="#"
@click.prevent="toggleMenu()"
>
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
</dropdown>
<base-icon name="globe" />
<span class="label">{{ current.code.toUpperCase() }}</span>
<base-icon class="dropdown-arrow" name="angle-down" />
</a>
<ds-menu
slot="popover"
slot-scope="{ toggleMenu }"
class="locale-menu-popover"
:matcher="matcher"
:routes="routes"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
class="locale-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="changeLanguage(item.route.path, toggleMenu)"
>
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
</dropdown>
</client-only>
</template>
<script>

View File

@ -1,71 +1,54 @@
<template>
<ds-container width="medium">
<ds-space margin="small">
<blockquote>
<p>{{ $t('quotes.african.quote') }}</p>
<b>- {{ $t('quotes.african.author') }}</b>
</blockquote>
</ds-space>
<ds-card class="login-card">
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
<client-only>
<locale-switch class="login-locale-switch" offset="5" />
</client-only>
<ds-space margin-top="small" margin-bottom="xxx-small" centered>
<img
class="login-image"
alt="Human Connection"
src="/img/sign-up/humanconnection.svg"
/>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
<ds-space margin="small">
<a :href="$t('login.moreInfoURL')" :title="$t('login.moreInfoHint')" target="_blank">
{{ $t('login.moreInfo') }}
</a>
</ds-space>
<ds-space margin="small">
<ds-text size="small">{{ $t('login.copy') }}</ds-text>
</ds-space>
<form :disabled="pending" @submit.prevent="onSubmit">
<ds-input
v-model="form.email"
:disabled="pending"
:placeholder="$t('login.email')"
type="email"
name="email"
icon="envelope"
/>
<ds-input
v-model="form.password"
:disabled="pending"
:placeholder="$t('login.password')"
icon="lock"
icon-right="question-circle"
name="password"
type="password"
/>
<ds-space margin-bottom="large">
<nuxt-link to="/password-reset/request">{{ $t('login.forgotPassword') }}</nuxt-link>
</ds-space>
<base-button :loading="pending" filled name="submit" type="submit" icon="sign-in">
{{ $t('login.login') }}
</base-button>
<ds-space margin-top="large" margin-bottom="x-small">
{{ $t('login.no-account') }}
<nuxt-link to="/registration/signup">{{ $t('login.register') }}</nuxt-link>
</ds-space>
</form>
</ds-flex-item>
</ds-flex>
</ds-card>
</ds-container>
<section class="login-form">
<blockquote>
<p>{{ $t('quotes.african.quote') }}</p>
<b>- {{ $t('quotes.african.author') }}</b>
</blockquote>
<base-card>
<template #imageColumn>
<a :href="$t('login.moreInfoURL')" :title="$t('login.moreInfo')" target="_blank">
<img class="image" alt="Human Connection" src="/img/sign-up/humanconnection.svg" />
</a>
</template>
<h2 class="title">{{ $t('login.login') }}</h2>
<form :disabled="pending" @submit.prevent="onSubmit">
<ds-input
v-model="form.email"
:disabled="pending"
:placeholder="$t('login.email')"
type="email"
name="email"
icon="envelope"
/>
<ds-input
v-model="form.password"
:disabled="pending"
:placeholder="$t('login.password')"
icon="lock"
icon-right="question-circle"
name="password"
type="password"
/>
<nuxt-link to="/password-reset/request">
{{ $t('login.forgotPassword') }}
</nuxt-link>
<base-button :loading="pending" filled name="submit" type="submit" icon="sign-in">
{{ $t('login.login') }}
</base-button>
<p>
{{ $t('login.no-account') }}
<nuxt-link to="/registration/signup">{{ $t('login.register') }}</nuxt-link>
</p>
</form>
<template #topMenu>
<locale-switch offset="5" />
</template>
</base-card>
</section>
</template>
<script>
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch.vue'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
export default {
components: {
@ -100,21 +83,16 @@ export default {
</script>
<style lang="scss">
.login-image {
width: 90%;
max-width: 200px;
}
.login-card {
position: relative;
.login-form {
width: 80vw;
max-width: 620px;
margin: auto;
.base-button {
display: block;
width: 100%;
margin-top: $space-large;
margin-bottom: $space-small;
}
}
.login-locale-switch {
position: absolute;
top: 1em;
left: 1em;
}
</style>

View File

@ -63,7 +63,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.commented_on_post',
)
})
@ -79,9 +79,9 @@ describe('Notification', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "read"', () => {
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
@ -90,8 +90,8 @@ describe('Notification', () => {
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
@ -113,7 +113,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.mentioned_in_post',
)
})
@ -125,9 +125,9 @@ describe('Notification', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.')
})
it('has no class "read"', () => {
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
@ -136,8 +136,8 @@ describe('Notification', () => {
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
@ -163,7 +163,7 @@ describe('Notification', () => {
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.mentioned_in_comment',
)
})
@ -182,9 +182,9 @@ describe('Notification', () => {
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "read"', () => {
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('read')
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
@ -193,8 +193,8 @@ describe('Notification', () => {
wrapper = Wrapper()
})
it('has class "read"', () => {
expect(wrapper.classes()).toContain('read')
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})

View File

@ -1,37 +1,23 @@
<template>
<ds-space :class="{ read: notification.read, notification: true }" margin-bottom="x-small">
<article :class="{ '--read': notification.read, notification: true }">
<client-only>
<ds-space margin-bottom="x-small">
<user-teaser :user="from.author" :date-time="from.createdAt" />
</ds-space>
<ds-text class="reason-text-for-test" color="soft">
{{ $t(`notifications.reason.${notification.reason}`) }}
</ds-text>
<user-teaser :user="from.author" :date-time="from.createdAt" />
</client-only>
<ds-space margin-bottom="x-small" />
<p class="description">{{ $t(`notifications.reason.${notification.reason}`) }}</p>
<nuxt-link
class="notification-mention-post"
class="link"
:to="{ name: 'post-id-slug', params, ...hashParam }"
@click.native="$emit('read')"
>
<ds-space margin-bottom="x-small">
<ds-card
:header="from.title || from.post.title"
hover
space="x-small"
class="notifications-card"
>
<ds-space margin-bottom="x-small" />
<div>
<span v-if="isComment" class="comment-notification-header">
{{ $t(`notifications.comment`) }}:
</span>
{{ from.contentExcerpt | removeHtml }}
</div>
</ds-card>
</ds-space>
<base-card wideContent>
<h2 class="title">{{ from.title || from.post.title }}</h2>
<p>
<strong v-if="isComment" class="comment">{{ $t(`notifications.comment`) }}:</strong>
{{ from.contentExcerpt | removeHtml }}
</p>
</base-card>
</nuxt-link>
</ds-space>
</article>
</template>
<script>
@ -70,14 +56,36 @@ export default {
</script>
<style lang="scss">
.notification.read {
opacity: $opacity-soft;
}
.notifications-card {
min-width: 500px;
}
.comment-notification-header {
font-weight: 700;
margin-right: 0.1rem;
.notification {
margin-bottom: $space-base;
&:first-of-type {
margin-top: $space-x-small;
}
&.--read {
opacity: $opacity-disabled;
}
> .description {
margin-bottom: $space-x-small;
}
> .link {
display: block;
color: $text-color-base;
&:hover {
color: $color-primary;
}
}
.user-teaser {
margin-bottom: $space-x-small;
}
.comment {
font-weight: $font-weight-bold;
}
}
</style>

View File

@ -73,7 +73,7 @@ describe('NotificationList.vue', () => {
describe('click on a notification', () => {
beforeEach(() => {
wrapper.find('.notification-mention-post').trigger('click')
wrapper.find('.notification > .link').trigger('click')
})
it("emits 'markAsRead' with the id of the notification source", () => {

View File

@ -3,7 +3,7 @@ import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import helpers from '~/storybook/helpers'
import { post } from '~/components/PostCard/PostCard.story.js'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
helpers.init()

View File

@ -1,219 +0,0 @@
<template>
<ds-card
:lang="post.language"
:image="post.image | proxyApiUrl"
:class="{
'post-card': true,
'disabled-content': post.disabled,
'--pinned': isPinned,
'--blur-image': post.imageBlurred,
}"
>
<!-- Post Link Target -->
<nuxt-link
class="post-link"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
{{ post.title }}
</nuxt-link>
<ds-space margin-bottom="small" />
<!-- Username, Image & Date of Post -->
<div class="user-wrapper">
<client-only>
<user-teaser :user="post.author" :date-time="post.createdAt" />
</client-only>
<hc-ribbon v-if="isPinned" class="ribbon--pinned" :text="$t('post.pinned')" />
<hc-ribbon v-else :text="$t('post.name')" />
</div>
<ds-space margin-bottom="small" />
<!-- Post Title -->
<ds-heading tag="h3" class="hyphenate-text post-title">{{ post.title }}</ds-heading>
<ds-space margin-bottom="small" />
<!-- Post Content Excerpt -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div class="hc-editor-content hyphenate-text" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<!-- Footer o the Post -->
<template slot="footer">
<div style="display: inline-block; opacity: .5;">
<!-- Categories -->
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.name.${category.slug}`),
placement: 'bottom-start',
delay: { show: 500 },
}"
:icon="category.icon"
/>
</div>
<client-only>
<div style="display: inline-block; float: right">
<!-- Shouts Count -->
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
<base-icon name="bullhorn" />
<small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<!-- Comments Count -->
<span :style="{ opacity: post.commentsCount ? 1 : 0.5 }">
<base-icon name="comments" />
<small>{{ post.commentsCount }}</small>
</span>
<!-- Menu -->
<content-menu
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
/>
</div>
</client-only>
</template>
</ds-card>
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcCategory from '~/components/Category'
import HcRibbon from '~/components/Ribbon'
// import { randomBytes } from 'crypto'
import { mapGetters } from 'vuex'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
export default {
name: 'HcPostCard',
components: {
UserTeaser,
HcCategory,
HcRibbon,
ContentMenu,
},
props: {
post: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
mounted() {
const width = this.$el.offsetWidth
const height = Math.min(width / this.post.imageAspectRatio, 2000)
const imageElement = this.$el.querySelector('.ds-card-image')
if (imageElement) {
imageElement.style.height = `${height}px`
}
},
computed: {
...mapGetters({
user: 'auth/user',
}),
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
isAuthor() {
const { author } = this.post
if (!author) return false
return this.user.id === this.post.author.id
},
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
isPinned() {
return this.post && this.post.pinned
},
},
methods: {
async deletePostCallback() {
try {
const {
data: { DeletePost },
} = await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$emit('removePostFromList', DeletePost)
} catch (err) {
this.$toast.error(err.message)
}
},
pinPost(post) {
this.$emit('pinPost', post)
},
unpinPost(post) {
this.$emit('unpinPost', post)
},
},
}
</script>
<style lang="scss">
.post-card {
justify-content: space-between;
position: relative;
z-index: 1;
cursor: pointer;
&.--pinned {
border: 1px solid $color-warning;
}
&.--blur-image > .ds-card-image img {
filter: blur(22px);
}
> .ds-card-image img {
width: 100%;
max-height: 2000px;
object-fit: contain;
}
> .ds-card-content {
flex-grow: 0;
}
/* workaround to avoid jumping layout when footer is rendered */
> .ds-card-footer {
height: 75px;
}
.post-title {
margin-top: $space-large;
}
/* workaround to avoid jumping layout when user-teaser is rendered */
.user-wrapper {
height: 36px;
position: relative;
z-index: $z-index-post-card-link;
}
.content-menu {
position: relative;
z-index: $z-index-post-card-link;
display: inline-block;
margin-left: $space-xx-small;
margin-right: -$space-x-small;
}
.post-link {
margin: 15px;
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-indent: -999999px;
}
}
</style>

View File

@ -2,14 +2,14 @@ import { config, shallowMount, mount, RouterLinkStub } from '@vue/test-utils'
import Vuex from 'vuex'
import PostCard from './PostCard.vue'
import PostTeaser from './PostTeaser.vue'
const localVue = global.localVue
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('PostCard', () => {
describe('PostTeaser', () => {
let store
let stubs
let mocks
@ -22,11 +22,13 @@ describe('PostCard', () => {
propsData = {
post: {
id: 'p23',
disabled: false,
shoutedCount: 0,
commentsCount: 0,
name: 'It is a post',
author: {
id: 'u1',
},
disabled: false,
},
}
stubs = {
@ -55,7 +57,7 @@ describe('PostCard', () => {
describe('shallowMount', () => {
Wrapper = () => {
store = new Vuex.Store({ getters })
return shallowMount(PostCard, {
return shallowMount(PostTeaser, {
store,
propsData,
mocks,
@ -63,6 +65,13 @@ describe('PostCard', () => {
})
}
it('has no validation errors', () => {
const spy = jest.spyOn(global.console, 'error')
Wrapper()
expect(spy).not.toBeCalled()
spy.mockReset()
})
beforeEach(jest.useFakeTimers)
describe('test Post callbacks', () => {
@ -99,7 +108,7 @@ describe('PostCard', () => {
const store = new Vuex.Store({
getters,
})
return mount(PostCard, {
return mount(PostTeaser, {
stubs,
mocks,
propsData,
@ -111,6 +120,7 @@ describe('PostCard', () => {
describe('given a post', () => {
beforeEach(() => {
propsData.post = {
...propsData.post,
title: "It's a title",
}
})

View File

@ -1,6 +1,6 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcPostCard from './PostCard.vue'
import PostTeaser from './PostTeaser.vue'
import helpers from '~/storybook/helpers'
helpers.init()
@ -44,24 +44,24 @@ export const post = {
__typename: 'Post',
}
storiesOf('Post Card', module)
storiesOf('PostTeaser', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('without image', () => ({
components: { HcPostCard },
components: { PostTeaser },
store: helpers.store,
data: () => ({
post,
}),
template: `
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>
`,
}))
.add('with image', () => ({
components: { HcPostCard },
components: { PostTeaser },
store: helpers.store,
data: () => ({
post: {
@ -70,27 +70,23 @@ storiesOf('Post Card', module)
},
}),
template: `
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>
`,
}))
.add('pinned by admin', () => ({
components: { HcPostCard },
components: { PostTeaser },
store: helpers.store,
data: () => ({
post: {
...post,
pinnedBy: {
id: '4711',
name: 'Ad Min',
role: 'admin',
},
pinned: true,
},
}),
template: `
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
/>

View File

@ -0,0 +1,207 @@
<template>
<nuxt-link
class="post-teaser"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
<base-card
:lang="post.language"
:class="{
'disabled-content': post.disabled,
'--blur-image': post.imageBlurred,
}"
:highlight="isPinned"
>
<template v-if="post.image" #heroImage>
<img :src="post.image | proxyApiUrl" class="image" />
</template>
<client-only>
<user-teaser :user="post.author" :date-time="post.createdAt" />
</client-only>
<h2 class="title hyphenate-text">{{ post.title }}</h2>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable vue/no-v-html -->
<div class="content hyphenate-text" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<footer class="footer">
<div class="categories">
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: $t(`contribution.category.name.${category.slug}`),
placement: 'bottom-start',
delay: { show: 500 },
}"
:icon="category.icon"
/>
</div>
<counter-icon
icon="bullhorn"
:count="post.shoutedCount"
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
/>
<counter-icon
icon="comments"
:count="post.commentsCount"
:title="$t('contribution.amount-comments', { amount: post.commentsCount })"
/>
<client-only>
<content-menu
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
/>
</client-only>
</footer>
</base-card>
<hc-ribbon
:class="{ '--pinned': isPinned }"
:text="isPinned ? $t('post.pinned') : $t('post.name')"
/>
</nuxt-link>
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import HcCategory from '~/components/Category'
import HcRibbon from '~/components/Ribbon'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { mapGetters } from 'vuex'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
export default {
name: 'PostTeaser',
components: {
UserTeaser,
HcCategory,
HcRibbon,
ContentMenu,
CounterIcon,
},
props: {
post: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
mounted() {
const width = this.$el.offsetWidth
const height = Math.min(width / this.post.imageAspectRatio, 2000)
const imageElement = this.$el.querySelector('.hero-image')
if (imageElement) {
imageElement.style.height = `${height}px`
}
},
computed: {
...mapGetters({
user: 'auth/user',
}),
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
isAuthor() {
const { author } = this.post
if (!author) return false
return this.user.id === this.post.author.id
},
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
isPinned() {
return this.post && this.post.pinned
},
},
methods: {
async deletePostCallback() {
try {
const {
data: { DeletePost },
} = await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$emit('removePostFromList', DeletePost)
} catch (err) {
this.$toast.error(err.message)
}
},
pinPost(post) {
this.$emit('pinPost', post)
},
unpinPost(post) {
this.$emit('unpinPost', post)
},
},
}
</script>
<style lang="scss">
.post-teaser,
.post-teaser:hover,
.post-teaser:active {
position: relative;
display: block;
height: 100%;
color: $text-color-base;
> .ribbon {
position: absolute;
top: 50%;
right: -7px;
}
}
.post-teaser > .base-card {
display: flex;
flex-direction: column;
height: 100%;
&.--blur-image > .hero-image > .image {
filter: blur($blur-radius);
}
> .content {
flex-grow: 1;
margin-bottom: $space-small;
}
> .footer {
display: flex;
justify-content: space-between;
align-items: center;
> .categories {
flex-grow: 1;
}
> .counter-icon {
display: block;
margin-right: $space-small;
opacity: $opacity-disabled;
}
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
.ds-tag {
margin: 0;
margin-right: $space-xx-small;
}
}
.user-teaser {
margin-bottom: $space-small;
}
}
</style>

View File

@ -45,13 +45,13 @@ export default {
border-style: solid;
border-color: $background-color-secondary transparent transparent $background-color-secondary;
}
}
.ribbon--pinned {
background-color: $color-warning-active;
&.--pinned {
background-color: $color-warning;
&::before {
border-color: $color-warning transparent transparent $color-warning;
&::before {
border-color: $color-warning transparent transparent $color-warning;
}
}
}
</style>

View File

@ -1,257 +0,0 @@
<template>
<vue-dropzone
:options="dropzoneOptions"
ref="el"
id="postdropzone"
class="ds-card-image"
:use-custom-slot="true"
@vdropzone-error="verror"
@vdropzone-thumbnail="transformImage"
>
<div class="crop-overlay" ref="cropperOverlay" v-show="showCropper">
<base-button @click="cropImage" class="crop-confirm" filled>
{{ $t('contribution.teaserImage.cropperConfirm') }}
</base-button>
<base-button
class="crop-cancel"
icon="close"
size="small"
circle
danger
filled
@click="cancelCrop"
/>
</div>
<div
:class="{
'hc-attachments-upload-area-post': true,
'hc-attachments-upload-area-update-post': contribution,
}"
>
<slot></slot>
<div
:class="{
'hc-drag-marker-post': true,
'hc-drag-marker-update-post': contribution,
}"
>
<base-icon name="image" />
</div>
</div>
</vue-dropzone>
</template>
<script>
import vueDropzone from 'nuxt-dropzone'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
export default {
components: {
vueDropzone,
},
props: {
contribution: { type: Object, default: () => {} },
},
data() {
return {
dropzoneOptions: {
url: () => '',
maxFilesize: 5.0,
previewTemplate: this.template(),
},
image: null,
file: null,
editor: null,
cropper: null,
thumbnailElement: null,
oldImage: null,
error: false,
showCropper: false,
imageAspectRatio: null,
}
},
methods: {
template() {
return `<div class="dz-preview dz-file-preview">
<div class="dz-image">
<div data-dz-thumbnail-bg></div>
</div>
</div>
`
},
verror(file, message) {
this.error = true
this.$toast.error(file.status, message)
setTimeout(() => {
this.error = false
}, 2000)
},
transformImage(file) {
this.file = file
this.$emit('cropInProgress', true)
this.showCropper = true
this.initEditor()
this.initCropper()
},
initEditor() {
this.editor = this.$refs.cropperOverlay
this.clearImages()
this.thumbnailElement.appendChild(this.editor)
},
clearImages() {
this.thumbnailElement = document.querySelectorAll('#postdropzone')[0]
const thumbnailPreview = document.querySelectorAll('.thumbnail-preview')[0]
if (thumbnailPreview) thumbnailPreview.remove()
const contributionImage = document.querySelectorAll('.contribution-image')[0]
this.oldImage = contributionImage
if (contributionImage) contributionImage.remove()
},
deleteImage() {
this.clearImages()
},
initCropper() {
this.image = new Image()
this.image.src = URL.createObjectURL(this.file)
this.editor.appendChild(this.image)
this.cropper = new Cropper(this.image, { zoomable: false, autoCropArea: 0.9 })
},
cropImage() {
this.showCropper = false
if (this.file.type === 'image/jpeg') {
this.uploadJpeg()
} else {
this.uploadOtherImageType()
}
},
uploadOtherImageType() {
this.imageAspectRatio = this.file.width / this.file.height || 1.0
this.image = new Image()
this.image.src = this.file.dataURL
this.setupPreview()
this.emitImageData(this.file)
},
uploadJpeg() {
const canvas = this.cropper.getCroppedCanvas()
canvas.toBlob(blob => {
this.imageAspectRatio = canvas.width / canvas.height
this.image = new Image()
this.image.src = canvas.toDataURL()
this.setupPreview()
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
this.emitImageData(croppedImageFile)
}, 'image/jpeg')
},
setupPreview() {
this.image.classList.add('thumbnail-preview')
this.thumbnailElement.appendChild(this.image)
},
cancelCrop() {
if (this.oldImage) this.thumbnailElement.appendChild(this.oldImage)
this.showCropper = false
this.$emit('cropInProgress', false)
},
emitImageData(imageFile) {
this.$emit('addTeaserImage', imageFile)
this.$emit('addImageAspectRatio', this.imageAspectRatio)
this.$emit('cropInProgress', false)
},
},
}
</script>
<style lang="scss">
#postdropzone {
width: 100%;
min-height: 400px;
background-color: $background-color-softest;
}
.hc-attachments-upload-area-post {
position: relative;
display: flex;
justify-content: center;
cursor: pointer;
}
.hc-attachments-upload-area-update-post img {
object-fit: cover;
object-position: center;
display: block;
width: 100%;
}
.hc-attachments-upload-area-update-post:hover {
opacity: 0.7;
}
.hc-drag-marker-post {
position: absolute;
width: 122px;
height: 122px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 180px 5px;
color: hsl(0, 0%, 25%);
transition: all 0.2s ease-out;
font-size: 60px;
background-color: $background-color-softest;
opacity: 0.65;
&:before {
position: absolute;
content: '';
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 100%;
border: 20px solid $text-color-base;
visibility: hidden;
}
&:after {
position: absolute;
content: '';
top: 10px;
left: 10px;
bottom: 10px;
right: 10px;
border-radius: 100%;
border: $border-size-base dashed $text-color-base;
}
.hc-attachments-upload-area-post:hover & {
opacity: 1;
}
}
.hc-drag-marker-update-post {
opacity: 0.1;
}
.contribution-form-footer {
border-top: $border-size-base solid $border-color-softest;
}
.crop-overlay {
max-height: 2000px;
position: relative;
width: 100%;
background-color: #000;
}
.crop-confirm {
position: absolute;
left: 10px;
top: 10px;
z-index: 1;
}
.crop-cancel {
position: absolute;
right: 10px;
top: 10px;
z-index: 1;
}
</style>

View File

@ -80,7 +80,7 @@ storiesOf('UserTeaser', module)
}),
template: `
<user-teaser :user="user" :date-time="new Date()">
<template v-slot:dateTime>
<template #dateTime>
- HEY! I'm edited
</template>
</user>

View File

@ -0,0 +1,78 @@
import { storiesOf } from '@storybook/vue'
import helpers from '~/storybook/helpers'
import BaseCard from './BaseCard.vue'
storiesOf('Generic/BaseCard', module)
.addDecorator(helpers.layout)
.add('default', () => ({
components: { BaseCard },
template: `
<base-card>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with slot: hero image', () => ({
components: { BaseCard },
template: `
<base-card style="width: 400px;">
<template #heroImage>
<img class="image" src="https://unsplash.com/photos/R4y_E5ZQDPg/download" />
</template>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with slot: image column', () => ({
components: { BaseCard },
template: `
<base-card style="width: 600px;">
<template #imageColumn>
<img class="image" src="/img/sign-up/humanconnection.svg" />
</template>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with slot: topMenu', () => ({
components: { BaseCard },
template: `
<base-card style="width: 600px;">
<template #imageColumn>
<img class="image" src="/img/sign-up/humanconnection.svg" />
</template>
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
<template #topMenu>
<base-button size="small">Menu</base-button>
</template>
</base-card>
`,
}))
.add('with highlight prop', () => ({
components: { BaseCard },
template: `
<base-card highlight style="width: 400px;">
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))
.add('with wideContent prop', () => ({
components: { BaseCard },
template: `
<base-card wideContent style="width: 400px;">
<h2 class="title">I am a card heading</h2>
<p>And I am a paragraph.</p>
</base-card>
`,
}))

View File

@ -0,0 +1,132 @@
<template>
<article :class="classNames">
<template v-if="$slots.imageColumn">
<aside class="image-column">
<slot name="imageColumn" />
</aside>
<section class="content-column">
<slot />
</section>
</template>
<template v-else-if="$slots.heroImage">
<section class="hero-image">
<slot name="heroImage" />
</section>
<slot />
</template>
<slot v-else />
<aside v-if="$slots.topMenu" class="top-menu">
<slot name="topMenu" />
</aside>
</article>
</template>
<script>
export default {
props: {
highlight: {
type: Boolean,
default: false,
},
wideContent: {
type: Boolean,
default: false,
},
},
computed: {
classNames() {
let classNames = 'base-card'
if (this.$slots.imageColumn) classNames += ' --columns'
if (this.highlight) classNames += ' --highlight'
if (this.wideContent) classNames += ' --wide-content'
return classNames
},
},
}
</script>
<style lang="scss">
.base-card {
position: relative;
padding: $space-base;
border-radius: $border-radius-x-large;
overflow: hidden;
background-color: $color-neutral-100;
box-shadow: $box-shadow-base;
&.--columns {
display: flex;
}
&.--highlight {
border: $border-size-base solid $color-warning;
}
&.--wide-content {
padding: $space-small;
> .hero-image {
width: calc(100% + (2 * #{$space-small}));
margin: -$space-small;
margin-bottom: $space-small;
}
}
> .title,
> .content-column > .title {
font-size: $font-size-large;
margin-bottom: $space-x-small;
}
> .hero-image {
width: calc(100% + (2 * #{$space-base}));
max-height: $size-image-max-height;
margin: -$space-base;
margin-bottom: $space-base;
overflow: hidden;
> .image {
width: 100%;
object-fit: contain;
}
}
> .image-column {
flex-basis: 50%;
display: flex;
justify-content: center;
align-items: center;
padding-right: $space-base;
.image {
width: 100%;
max-width: 200px;
}
}
> .content-column {
flex-basis: 50%;
}
> .top-menu {
position: absolute;
top: $space-small;
left: $space-small;
}
}
@media (max-width: 565px) {
.base-card.--columns {
flex-direction: column;
> .image-column {
padding-right: 0;
margin-bottom: $space-base;
}
}
}
</style>

View File

@ -1,7 +1,7 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import { post } from '~/components/PostCard/PostCard.story.js'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
import helpers from '~/storybook/helpers'
import ReportList from './ReportList'
@ -183,11 +183,11 @@ storiesOf('ReportList', module)
openModal: action('openModal'),
filter: action('filter'),
},
template: `<ds-card>
template: `<base-card>
<div class="reports-header">
<h3 class="title">Reports</h3>
<dropdown-filter @filter="filter" :filterOptions="filterOptions" :selected="selected" />
</div>
<reports-table :reports="reports" @confirm="openModal" />
</ds-card>`,
</base-card>`,
}))

View File

@ -1,5 +1,5 @@
<template>
<ds-card>
<base-card>
<div class="reports-header">
<h3 class="title">{{ $t('moderation.reports.name') }}</h3>
<client-only>
@ -8,8 +8,9 @@
</div>
<reports-table :reports="reports" @confirm="openModal" />
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
</ds-card>
</base-card>
</template>
<script>
import { mapMutations } from 'vuex'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
@ -166,7 +167,7 @@ export default {
.reports-header {
display: flex;
justify-content: space-between;
margin: $space-small 0;
margin-bottom: $space-small;
> .title {
margin: 0;

View File

@ -10,9 +10,7 @@
</a>
</ds-flex-item>
<ds-flex-item width="20%" style="flex-grow:0;">
<client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="16" />
</client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="16" />
</ds-flex-item>
</ds-flex>
</ds-container>

View File

@ -50,9 +50,7 @@
}"
style="flex-basis: auto;"
>
<client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
</client-only>
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
<template v-if="isLoggedIn">
<client-only>
<notification-menu placement="top" />

View File

@ -217,6 +217,8 @@
}
},
"contribution": {
"amount-comments": "{amount} comments",
"amount-shouts": "{amount} recommendations",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} von {max} Kategorien ausgewählt"
},
@ -635,12 +637,12 @@
"success": "Deine Daten wurden erfolgreich aktualisiert!"
},
"deleteUserAccount": {
"accountDescription": "Sei Dir bewusst, dass Deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn Du sie trotzdem löschen möchtest, musst Du sie unten markieren.",
"accountWarning": "Dein Konto, Deine Beiträge oder Kommentare kannst Du nach dem Löschen <b>WEDER VERWALTEN NOCH WIEDERHERSTELLEN!</b>",
"accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.",
"accountWarning": "Dein Konto, deine Beiträge oder Kommentare kannst du nach dem Löschen WEDER VERWALTEN NOCH WIEDERHERSTELLEN!",
"commentedCount": "Meine {count} Kommentare löschen",
"contributionsCount": "Meine {count} Beiträge löschen",
"name": "Benutzerkonto löschen",
"pleaseConfirm": "<b class='is-danger'>Zerstörerische Aktion!</b> Gib <b>{confirm}</b> ein, um zu bestätigen.",
"pleaseConfirm": "Zerstörerische Aktion! Gib „{confirm}“ ein, um zu bestätigen.",
"success": "Konto erfolgreich gelöscht!"
},
"download": {

View File

@ -217,6 +217,8 @@
}
},
"contribution": {
"amount-comments": "{amount} comments",
"amount-shouts": "{amount} recommendations",
"categories": {
"infoSelectedNoOfMaxCategories": "{chosen} of {max} categories selected"
},
@ -636,11 +638,11 @@
},
"deleteUserAccount": {
"accountDescription": "Be aware that your Posts and Comments are important to our community. If you still choose to delete them, you have to mark them below.",
"accountWarning": "You <b>CAN'T MANAGE</b> and <b>CAN'T RECOVER</b> your Account, Posts, or Comments after deleting your account!",
"accountWarning": "You CAN'T MANAGE and CAN'T RECOVER your Account, Posts, or Comments after deleting your account!",
"commentedCount": "Delete my {count} comments",
"contributionsCount": "Delete my {count} posts",
"name": "Delete user account",
"pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm",
"pleaseConfirm": "Destructive action! Type “{confirm}” to confirm.",
"success": "Account successfully deleted!"
},
"download": {

View File

@ -609,11 +609,11 @@
},
"deleteUserAccount": {
"accountDescription": "Tenga en cuenta que su contribución y sus comentarios son importantes para nuestra comunidad. Si aún decide borrarlos, debe marcarlos a continuación.",
"accountWarning": <b> NO PUEDE GESTIONAR </b> y <b> NO PUEDE RECUPERAR </b> su cuenta, contribuciones o comentarios después de eliminar su cuenta!",
"accountWarning": NO PUEDE GESTIONAR y NO PUEDE RECUPERAR su cuenta, contribuciones o comentarios después de eliminar su cuenta!",
"commentedCount": "Eliminar mis {count} comentarios",
"contributionsCount": "Eliminar mis {count} contribuciones",
"name": "Eliminar cuenta de usuario",
"pleaseConfirm": "<b class='is-danger'> ¡Acción destructiva! </b> Escriba <b> {confirm} </b> para confirmar",
"pleaseConfirm": "¡Acción destructiva! Escriba “{confirm}” para confirmar.",
"success": "¡Cuenta eliminada con éxito!"
},
"download": {

View File

@ -159,7 +159,7 @@
"project": "Projet ::: Projets",
"reportContent": "Signaler",
"shout": "Partage ::: Partages",
"tag": "Tag ::: Tags",
"tag": "Tag ::: Tags",
"takeAction": "Passer à l'action",
"user": "Utilisateur ::: Utilisateurs",
"validations": {
@ -551,11 +551,11 @@
},
"deleteUserAccount": {
"accountDescription": "Sachez que vos postes et commentaires sont importants pour notre communauté. Si vous voulez quand même les supprimer, vous devez les marquer ci-dessous.",
"accountWarning": "Vous <b>NE POUVEZ PAS GÉRER</b> et <b>NE POUVEZ PAS RECOUVRIR</b> votre compte, vos messages ou vos commentaires après avoir supprimé votre compte !",
"accountWarning": "Vous NE POUVEZ PAS GÉRER et NE POUVEZ PAS RECOUVRIR votre compte, vos messages ou vos commentaires après avoir supprimé votre compte!",
"commentedCount": "Supprimer mes {count} commentaires",
"contributionsCount": "Supprimer mes {count} postes",
"name": "Supprimer un compte utilisateur",
"pleaseConfirm": "<b class='is-danger'> Action destructive! </b> Saisissez <b> {confirm} </b> pour confirmer",
"pleaseConfirm": "Action destructive! Saisissez “{confirm}” pour confirmer.",
"success": "Compte supprimer avec succès!"
},
"download": {

View File

@ -551,11 +551,11 @@
},
"deleteUserAccount": {
"accountDescription": "Essere consapevoli che i tuoi post e commenti sono importanti per la nostra comunità. Se cancelli il tuo account utente, tutto scomparirà per sempre - e sarebbe un vero peccato!",
"accountWarning": "Attenzione!Tu <b>Non puoi gestire</b> e <b>Non puoi recuperare il tuo account, i tuoi messaggi o commenti dopo aver cancellato il tuo account!",
"accountWarning": "Attenzione! Tu NON PUOI GESTIRE e NON PUOI RECUPERARE il tuo account, i tuoi messaggi o commenti dopo aver cancellato il tuo account!",
"commentedCount": "Cancella i miei {count} commenti",
"contributionsCount": "Cancellare i miei {count} messaggi",
"name": "Cancellare l'account utente",
"pleaseConfirm": "<b class='is-danger'>Azione distruttiva! </b> Digita <b>{conferma}</b> per confermare",
"pleaseConfirm": "Azione distruttiva! Digita “{confirm}” per confermare.",
"success": "Account eliminato con successo!"
},
"download": {

View File

@ -295,11 +295,11 @@
},
"deleteUserAccount": {
"accountDescription": "Be aware that your Post and Comments are important to our community. If you still choose to delete them, you have to mark them below.",
"accountWarning": "Po usunięcie Twojego konta, nie możesz <b>ZARZĄDZAĆ</b> ani <b>ODZYSKAĆ</b> danych, wpisów oraz komentarzy!",
"accountWarning": "Po usunięcie Twojego konta, nie możesz ZARZĄDZAĆ ani ODZYSKAĆ danych, wpisów oraz komentarzy!",
"commentedCount": "Usuń {count} moich komentarzy",
"contributionsCount": "Usuń {count} moich postów",
"name": "Usuń dane",
"pleaseConfirm": "<b class='is-danger'>Uwaga, niebezpieczeństwo!</b> Wpisz <b>{confirm}</b>, aby potwierdzić",
"pleaseConfirm": "Uwaga, niebezpieczeństwo! Wpisz „{confirm}”, aby potwierdzić.",
"success": "Konto zostało usunięte"
},
"download": {

View File

@ -551,11 +551,11 @@
},
"deleteUserAccount": {
"accountDescription": "Esteja ciente de que o suas Publicações e Comentários são importantes para a nossa comunidade. Se você ainda optar por excluí-los, você tem que marcá-los abaixo.",
"accountWarning": "Você <b>NÃO PODE GERENCIAR</b> e <b>NÃO PODE RECUPERAR</b> sua conta, Publicações, ou Comentários após excluir sua conta!",
"accountWarning": "Você NÃO PODE GERENCIAR e NÃO PODE RECUPERAR sua conta, Publicações, ou Comentários após excluir sua conta!",
"commentedCount": "Deletar meus {count} comentários",
"contributionsCount": "Deletar minhas {count} publicações",
"name": "Deletar dados",
"pleaseConfirm": "<b class='is-danger'>Ação destrutiva!</b> Digitar <b>{confirm}</b> para confirmar",
"pleaseConfirm": "Ação destrutiva! Digitar “{confirm}” para confirmar.",
"success": "Conta eliminada com sucesso!"
},
"download": {

View File

@ -609,11 +609,11 @@
},
"deleteUserAccount": {
"accountDescription": "Обратите внимание, что ваши посты и комментарии важны для сообщества. Если вы все равно хотите их удалить, то вы должны отметить соответствующие опции ниже.",
"accountWarning": "Вы <b>НЕ СМОЖЕТЕ</b> восстановить свой аккаунт, посты или комментарии после удаления.",
"accountWarning": "Вы НЕ СМОЖЕТЕ восстановить свой аккаунт, посты или комментарии после удаления.",
"commentedCount": "Удалить мои комментарии: {count}",
"contributionsCount": "Удалить мои посты: {count}",
"name": "Удалить данные",
"pleaseConfirm": "<b class='is-danger'>Разрушительное действие!</b> Введите <b>{confirm}</b> для подтверждения.",
"pleaseConfirm": "Разрушительное действие! Введите „{confirm}“ для подтверждения.",
"success": "Аккаунт успешно удален!"
},
"download": {

View File

@ -1,11 +1,9 @@
<template>
<transition name="fade" appear>
<ds-container width="medium">
<ds-card>
<base-card>
<ds-space>
<client-only>
<locale-switch class="login-locale-switch" offset="5" />
</client-only>
<locale-switch class="login-locale-switch" offset="5" />
</ds-space>
<ds-flex>
<ds-flex-item :width="{ base: '100%', sm: 1, md: 1 }">
@ -31,7 +29,7 @@
</ds-flex-item>
</ds-flex-item>
</ds-flex>
</ds-card>
</base-card>
</ds-container>
</transition>
</template>

View File

@ -1,11 +1,12 @@
<template>
<ds-card :header="$t('admin.categories.name')">
<base-card>
<h2 class="title">{{ $t('admin.categories.name') }}</h2>
<ds-table :data="Category" :fields="fields" condensed>
<template slot="icon" slot-scope="scope">
<base-icon :name="scope.row.icon" />
</template>
</ds-table>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,5 +1,6 @@
<template>
<ds-card :header="$t('admin.donations.name')">
<base-card>
<h2 class="title">{{ $t('admin.donations.name') }}</h2>
<ds-form v-model="formData" @submit="submit">
<ds-input model="goal" :label="$t('admin.donations.goal')" placeholder="15000" icon="money" />
<ds-input
@ -12,7 +13,7 @@
{{ $t('actions.save') }}
</base-button>
</ds-form>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,5 +1,6 @@
<template>
<ds-card :header="$t('admin.hashtags.name')">
<base-card>
<h2 class="title">{{ $t('admin.hashtags.name') }}</h2>
<ds-table :data="Tag" :fields="fields" condensed>
<template slot="index" slot-scope="scope">{{ scope.index + 1 }}.</template>
<template slot="id" slot-scope="scope">
@ -8,7 +9,7 @@
</nuxt-link>
</template>
</ds-table>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,5 +1,5 @@
<template>
<ds-card>
<base-card>
<ApolloQuery :query="Statistics">
<template v-slot="{ result: { loading, error, data } }">
<template v-if="loading">
@ -123,7 +123,7 @@
</template>
</template>
</ApolloQuery>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,13 +1,12 @@
<template>
<!-- This registration form will be visible to the public as soon as the registration is open to the public. -->
<ds-section>
<ds-space>
<ds-heading size="h3">{{ $t('admin.invites.title') }}</ds-heading>
<ds-text>{{ $t('admin.invites.description') }}</ds-text>
</ds-space>
<ds-card class="signup">
<base-card>
<signup :invitation="true" />
</ds-card>
</base-card>
</ds-section>
</template>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('admin.notifications.name')">
<base-card>
<h2 class="title">{{ $t('admin.notifications.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('admin.organizations.name')">
<base-card>
<h2 class="title">{{ $t('admin.organizations.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('admin.pages.name')">
<base-card>
<h2 class="title">{{ $t('admin.pages.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('admin.settings.name')">
<base-card>
<h2 class="title">{{ $t('admin.settings.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,24 +1,23 @@
<template>
<div>
<ds-space>
<ds-card :header="$t('admin.users.name')">
<ds-form v-model="form" @submit="submit">
<ds-flex gutter="small">
<ds-flex-item width="90%">
<ds-input
model="query"
:placeholder="$t('admin.users.form.placeholder')"
icon="search"
/>
</ds-flex-item>
<ds-flex-item width="30px">
<base-button filled circle type="submit" icon="search" :loading="$apollo.loading" />
</ds-flex-item>
</ds-flex>
</ds-form>
</ds-card>
</ds-space>
<ds-card v-if="User && User.length">
<div class="admin-users">
<base-card>
<h2 class="title">{{ $t('admin.users.name') }}</h2>
<ds-form v-model="form" @submit="submit">
<ds-flex gutter="small">
<ds-flex-item width="90%">
<ds-input
model="query"
:placeholder="$t('admin.users.form.placeholder')"
icon="search"
/>
</ds-flex-item>
<ds-flex-item width="30px">
<base-button filled circle type="submit" icon="search" :loading="$apollo.loading" />
</ds-flex-item>
</ds-flex>
</ds-form>
</base-card>
<base-card v-if="User && User.length">
<ds-table :data="User" :fields="fields" condensed>
<template slot="index" slot-scope="scope">{{ scope.row.index + 1 }}.</template>
<template slot="name" slot-scope="scope">
@ -51,10 +50,10 @@
</template>
</ds-table>
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @next="next" @back="back" />
</ds-card>
<ds-card v-else>
</base-card>
<base-card v-else>
<ds-placeholder>{{ $t('admin.users.empty') }}</ds-placeholder>
</ds-card>
</base-card>
</div>
</template>
@ -178,3 +177,9 @@ export default {
},
}
</script>
<style lang="scss">
.admin-users > .base-card:first-child {
margin-bottom: $space-small;
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div>
<masonry-grid>
<ds-grid-item v-show="hashtag" :row-span="2" column-span="fullWidth">
<ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth">
<filter-menu :hashtag="hashtag" @clearSearch="clearSearch" />
</ds-grid-item>
<ds-grid-item :row-span="2" column-span="fullWidth" class="top-info-bar">
@ -26,7 +26,7 @@
:key="post.id"
:imageAspectRatio="post.imageAspectRatio"
>
<hc-post-card
<post-teaser
:post="post"
@removePostFromList="deletePost"
@pinPost="pinPost"
@ -67,7 +67,7 @@
// import DonationInfo from '~/components/DonationInfo/DonationInfo.vue'
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import HcEmpty from '~/components/Empty/Empty'
import HcPostCard from '~/components/PostCard/PostCard.vue'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import { mapGetters, mapMutations } from 'vuex'
@ -79,7 +79,7 @@ export default {
components: {
// DonationInfo,
FilterMenu,
HcPostCard,
PostTeaser,
HcEmpty,
MasonryGrid,
MasonryGridItem,
@ -219,11 +219,6 @@ export default {
</script>
<style lang="scss">
.ds-card-image img {
max-height: 2000px;
object-fit: contain;
}
.masonry-grid {
display: grid;
grid-gap: 10px;

View File

@ -1,5 +1,5 @@
<template>
<ds-card space="small">
<base-card>
<ds-flex class="notifications-page-flex">
<ds-flex-item :width="{ lg: '85%' }">
<ds-heading tag="h3">{{ $t('notifications.title') }}</ds-heading>
@ -16,7 +16,7 @@
:notifications="notifications"
/>
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,20 +1,14 @@
<template>
<ds-container width="small">
<ds-card>
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', sm: '40%' }">
<client-only>
<locale-switch offset="5" />
</client-only>
<ds-space margin-top="small" margin-bottom="xxx-small" centered>
<img alt="Human Connection" src="/icon.png" />
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '60%' }" centered>
<nuxt-child />
</ds-flex-item>
</ds-flex>
</ds-card>
<ds-container width="small" class="password-reset">
<base-card>
<template #imageColumn>
<img alt="Human Connection" src="/icon.png" class="image" />
</template>
<nuxt-child />
<template #topMenu>
<locale-switch offset="5" />
</template>
</base-card>
</ds-container>
</template>
@ -33,10 +27,3 @@ export default {
},
}
</script>
<style lang="scss" scoped>
img {
padding-left: 50px;
padding-right: 50px;
max-width: 200px;
}
</style>

View File

@ -1,42 +1,46 @@
<template>
<transition name="fade" appear>
<ds-card
:lang="post.language"
<base-card
v-if="post && ready"
:image="post.image | proxyApiUrl"
:lang="post.language"
:class="{
'post-page': true,
'disabled-content': post.disabled,
'--blur-image': blurred,
}"
>
<aside v-show="post.imageBlurred" class="blur-toggle">
<img v-show="blurred" :src="post.image | proxyApiUrl" class="preview" />
<base-button
:icon="blurred ? 'eye' : 'eye-slash'"
filled
circle
@click="blurred = !blurred"
/>
</aside>
<user-teaser :user="post.author" :date-time="post.createdAt">
<template v-slot:dateTime>
<ds-text v-if="post.createdAt !== post.updatedAt">({{ $t('post.edited') }})</ds-text>
</template>
</user-teaser>
<client-only>
<content-menu
placement="bottom-end"
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
/>
</client-only>
<template #heroImage v-if="post.image">
<img :src="post.image | proxyApiUrl" class="image" />
<aside v-show="post.imageBlurred" class="blur-toggle">
<img v-show="blurred" :src="post.image | proxyApiUrl" class="preview" />
<base-button
:icon="blurred ? 'eye' : 'eye-slash'"
filled
circle
@click="blurred = !blurred"
/>
</aside>
</template>
<section class="menu">
<user-teaser :user="post.author" :date-time="post.createdAt">
<template #dateTime>
<ds-text v-if="post.createdAt !== post.updatedAt">({{ $t('post.edited') }})</ds-text>
</template>
</user-teaser>
<client-only>
<content-menu
placement="bottom-end"
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
/>
</client-only>
</section>
<ds-space margin-bottom="small" />
<ds-heading tag="h3" no-margin class="hyphenate-text">{{ post.title }}</ds-heading>
<h2 class="title hyphenate-text">{{ post.title }}</h2>
<ds-space margin-bottom="small" />
<content-viewer class="content hyphenate-text" :content="post.content" />
<!-- eslint-enable vue/no-v-html -->
@ -83,13 +87,8 @@
</ds-flex>
</ds-space>
<!-- Comments -->
<ds-section slot="footer">
<comment-list
:post="post"
:routeHash="$route.hash"
@toggleNewCommentForm="toggleNewCommentForm"
@reply="reply"
/>
<ds-section>
<comment-list :post="post" @toggleNewCommentForm="toggleNewCommentForm" @reply="reply" />
<ds-space margin-bottom="large" />
<comment-form
v-if="showNewCommentForm && !isBlocked"
@ -104,7 +103,7 @@
<a href="https://support.human-connection.org/kb/" target="_blank">FAQ</a>
</ds-placeholder>
</ds-section>
</ds-card>
</base-card>
</transition>
</template>
@ -246,18 +245,23 @@ export default {
</script>
<style lang="scss">
.post-page {
&.--blur-image > .ds-card-image img {
filter: blur(22px);
> .hero-image {
position: relative;
}
.ds-card-content {
position: relative;
padding-top: 24px;
> .menu {
display: flex;
justify-content: space-between;
align-items: center;
}
&.--blur-image > .hero-image > .image {
filter: blur($blur-radius);
}
.blur-toggle {
position: absolute;
top: -80px;
bottom: 0;
right: 0;
display: flex;
@ -272,40 +276,13 @@ export default {
}
}
.content-menu {
float: right;
margin-right: -$space-x-small;
margin-top: -$space-large;
}
.comments {
margin-top: $space-small;
.comment {
margin-top: $space-small;
position: relative;
}
.ProseMirror {
min-height: 0px;
}
}
.ds-card-image {
img {
max-height: 2000px;
object-fit: contain;
object-position: center;
}
}
.ds-card-footer {
padding: 0;
.ds-section {
padding: $space-base;
}
}
}
@media only screen and (max-width: 960px) {

View File

@ -1,6 +1,6 @@
<template>
<ds-card>
<h2 style="margin-bottom: .2em;">{{ $t('post.moreInfo.title') }}</h2>
<base-card>
<h2 class="title">{{ $t('post.moreInfo.title') }}</h2>
<p>{{ $t('post.moreInfo.description') }}</p>
<ds-space />
<h3>{{ $t('post.moreInfo.titleOfCategoriesSection') }}</h3>
@ -24,7 +24,7 @@
:key="relatedPost.id"
:imageAspectRatio="relatedPost.imageAspectRatio"
>
<hc-post-card
<post-teaser
:post="relatedPost"
:width="{ base: '100%', lg: 1 }"
@removePostFromList="removePostFromList"
@ -33,12 +33,12 @@
</masonry-grid>
<hc-empty v-else margin="large" icon="file" message="No related Posts" />
</ds-section>
</ds-card>
</base-card>
</template>
<script>
import HcEmpty from '~/components/Empty/Empty'
import HcPostCard from '~/components/PostCard/PostCard.vue'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcCategory from '~/components/Category'
import HcHashtag from '~/components/Hashtag/Hashtag'
import { relatedContributions } from '~/graphql/PostQuery'
@ -51,7 +51,7 @@ export default {
mode: 'out-in',
},
components: {
HcPostCard,
PostTeaser,
HcCategory,
HcHashtag,
HcEmpty,

View File

@ -1,7 +1,8 @@
<template>
<ds-card header="Werde aktiv!">
<base-card>
<h2 class="title">Werde aktiv!</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,14 +1,11 @@
<template>
<div>
<ds-card v-if="user && user.image">
<p>PROFILE IMAGE</p>
</ds-card>
<ds-space />
<ds-flex v-if="user" :width="{ base: '100%' }" gutter="base">
<ds-flex-item :width="{ base: '100%', sm: 2, md: 2, lg: 1 }">
<ds-card
<base-card
:class="{ 'disabled-content': user.disabled }"
style="position: relative; height: auto;"
style="position: relative; height: auto; overflow: visible;"
>
<hc-upload v-if="myProfile" :user="user">
<user-avatar :user="user" class="profile-avatar" size="large"></user-avatar>
@ -87,12 +84,12 @@
<ds-text color="soft" size="small" class="hyphenate-text">{{ user.about }}</ds-text>
</ds-space>
</template>
</ds-card>
</base-card>
<ds-space />
<ds-heading tag="h3" soft style="text-align: center; margin-bottom: 10px;">
{{ $t('profile.network.title') }}
</ds-heading>
<ds-card style="position: relative; height: auto;">
<base-card style="position: relative; height: auto;">
<ds-space v-if="user.following && user.following.length" margin="x-small">
<ds-text tag="h5" color="soft">
{{ userName | truncate(15) }} {{ $t('profile.network.following') }}
@ -120,9 +117,9 @@
{{ userName }} {{ $t('profile.network.followingNobody') }}
</p>
</template>
</ds-card>
</base-card>
<ds-space />
<ds-card style="position: relative; height: auto;">
<base-card style="position: relative; height: auto;">
<ds-space v-if="user.followedBy && user.followedBy.length" margin="x-small">
<ds-text tag="h5" color="soft">
{{ userName | truncate(15) }} {{ $t('profile.network.followedBy') }}
@ -150,9 +147,9 @@
{{ userName }} {{ $t('profile.network.followedByNobody') }}
</p>
</template>
</ds-card>
</base-card>
<ds-space v-if="user.socialMedia && user.socialMedia.length" margin="large">
<ds-card style="position: relative; height: auto;">
<base-card style="position: relative; height: auto;">
<ds-space margin="x-small">
<ds-text tag="h5" color="soft">
{{ $t('profile.socialMedia') }} {{ userName | truncate(15) }}?
@ -166,14 +163,14 @@
</ds-space>
</template>
</ds-space>
</ds-card>
</base-card>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<masonry-grid>
<ds-grid-item class="profile-top-navigation" :row-span="3" column-span="fullWidth">
<ds-card class="ds-tab-nav">
<base-card class="ds-tab-nav">
<ul class="Tabs">
<li class="Tabs__tab pointer" :class="{ active: tabActive === 'post' }">
<a @click="handleTab('post')">
@ -213,7 +210,7 @@
</a>
</li>
</ul>
</ds-card>
</base-card>
</ds-grid-item>
<ds-grid-item :row-span="2" column-span="fullWidth">
@ -242,7 +239,7 @@
:key="post.id"
:imageAspectRatio="post.imageAspectRatio"
>
<hc-post-card
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="removePostFromList"
@ -275,7 +272,7 @@
<script>
import uniqBy from 'lodash/uniqBy'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcPostCard from '~/components/PostCard/PostCard.vue'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcFollowButton from '~/components/FollowButton.vue'
import HcCountTo from '~/components/CountTo.vue'
import HcBadges from '~/components/Badges.vue'
@ -303,7 +300,7 @@ const tabToFilterMapping = ({ tab, id }) => {
export default {
components: {
UserTeaser,
HcPostCard,
PostTeaser,
HcFollowButton,
HcCountTo,
HcBadges,
@ -562,18 +559,17 @@ export default {
top: 53px;
z-index: 2;
}
.ds-tab-nav {
.ds-card-content {
padding: 0 !important;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;
&:first-child {
border-bottom-left-radius: $border-radius-x-large;
}
&:last-child {
border-bottom-right-radius: $border-radius-x-large;
}
.ds-tab-nav.base-card {
padding: 0;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;
&:first-child {
border-bottom-left-radius: $border-radius-x-large;
}
&:last-child {
border-bottom-right-radius: $border-radius-x-large;
}
}
}

View File

@ -1,25 +1,20 @@
<template>
<ds-container width="medium">
<ds-card>
<ds-flex gutter="small">
<ds-flex-item :width="{ base: '100%', sm: '50%' }">
<client-only>
<locale-switch offset="5" />
</client-only>
<ds-space margin-top="small" margin-bottom="xxx-small">
<img class="signup-image" alt="Human Connection" src="/img/sign-up/nicetomeetyou.svg" />
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
<nuxt-child />
</ds-flex-item>
</ds-flex>
</ds-card>
<base-card>
<template #imageColumn>
<img alt="Human Connection" src="/img/sign-up/nicetomeetyou.svg" />
</template>
<nuxt-child />
<template #topMenu>
<locale-switch offset="5" />
</template>
</base-card>
</ds-container>
</template>
<script>
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
export default {
components: {
LocaleSwitch,

View File

@ -1,7 +1,8 @@
<template>
<div>
<ds-space>
<ds-card :header="$t('settings.blocked-users.name')">
<base-card>
<h2 class="title">{{ $t('settings.blocked-users.name') }}</h2>
<ds-text>
{{ $t('settings.blocked-users.explanation.intro') }}
</ds-text>
@ -16,9 +17,9 @@
{{ $t('settings.blocked-users.explanation.notifications') }}
</ds-list-item>
</ds-list>
</ds-card>
</base-card>
</ds-space>
<ds-card v-if="blockedUsers && blockedUsers.length">
<base-card v-if="blockedUsers && blockedUsers.length">
<ds-table :data="blockedUsers" :fields="fields" condensed>
<template #avatar="scope">
<nuxt-link
@ -55,8 +56,8 @@
<base-button circle size="small" @click="unblockUser(scope)" icon="user-plus" />
</template>
</ds-table>
</ds-card>
<ds-card v-else>
</base-card>
<base-card v-else>
<ds-space>
<ds-placeholder>
{{ $t('settings.blocked-users.empty') }}
@ -67,7 +68,7 @@
{{ $t('settings.blocked-users.how-to') }}
</ds-text>
</ds-space>
</ds-card>
</base-card>
</div>
</template>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('settings.download.name')">
<base-card>
<h2 class="title">{{ $t('settings.download.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,5 +1,6 @@
<template>
<ds-card :header="$t('settings.embeds.name')">
<base-card>
<h2 class="title">{{ $t('settings.embeds.name') }}</h2>
<ds-section>
<ds-text>
{{ $t('settings.embeds.status.description') }}
@ -31,7 +32,7 @@
</li>
</ul>
</ds-section>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-form v-model="form" :schema="formSchema" @submit="submit">
<template slot-scope="{ errors }">
<ds-card :header="$t('settings.data.name')">
<base-card>
<h2 class="title">{{ $t('settings.data.name') }}</h2>
<ds-input
id="name"
model="name"
@ -30,12 +31,10 @@
:label="$t('settings.data.labelBio')"
:placeholder="$t('settings.data.labelBio')"
/>
<template slot="footer">
<base-button icon="check" :disabled="errors" type="submit" :loading="loadingData" filled>
{{ $t('actions.save') }}
</base-button>
</template>
</ds-card>
<base-button icon="check" :disabled="errors" type="submit" :loading="loadingData" filled>
{{ $t('actions.save') }}
</base-button>
</base-card>
</template>
</ds-form>
</template>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('settings.invites.name')">
<base-card>
<h2 class="title">{{ $t('settings.invites.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('settings.languages.name')">
<base-card>
<h2 class="title">{{ $t('settings.languages.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<div>
<ds-space>
<ds-card :header="$t('settings.muted-users.name')">
<base-card>
<h2 class="title">{{ $t('settings.muted-users.name') }}</h2>
<ds-text>
{{ $t('settings.muted-users.explanation.intro') }}
</ds-text>
@ -13,9 +14,9 @@
{{ $t('settings.muted-users.explanation.search') }}
</ds-list-item>
</ds-list>
</ds-card>
</base-card>
</ds-space>
<ds-card v-if="mutedUsers && mutedUsers.length">
<base-card v-if="mutedUsers && mutedUsers.length">
<ds-table :data="mutedUsers" :fields="fields" condensed>
<template #avatar="scope">
<nuxt-link
@ -52,8 +53,8 @@
<base-button circle size="small" @click="unmuteUser(scope)" icon="user-plus" />
</template>
</ds-table>
</ds-card>
<ds-card v-else>
</base-card>
<base-card v-else>
<ds-space>
<ds-placeholder>
{{ $t('settings.muted-users.empty') }}
@ -64,7 +65,7 @@
{{ $t('settings.muted-users.how-to') }}
</ds-text>
</ds-space>
</ds-card>
</base-card>
</div>
</template>

View File

@ -1,7 +1,8 @@
<template>
<ds-form v-model="form" :schema="formSchema" @submit="submit">
<template slot-scope="{ errors }">
<ds-card :header="$t('settings.email.name')">
<base-card>
<h2 class="title">{{ $t('settings.email.name') }}</h2>
<ds-input
id="email"
model="email"
@ -15,13 +16,10 @@
icon="question-circle"
:label="$t('settings.email.labelNonce')"
/>
<template slot="footer">
<base-button class="submit-button" icon="check" :disabled="errors" type="submit" filled>
{{ $t('actions.save') }}
</base-button>
</template>
</ds-card>
<base-button icon="check" :disabled="errors" type="submit" filled>
{{ $t('actions.save') }}
</base-button>
</base-card>
</template>
</ds-form>
</template>

View File

@ -1,29 +1,27 @@
<template>
<ds-card centered v-if="data">
<base-card v-if="data">
<transition name="ds-transition-fade">
<sweetalert-icon icon="info" />
</transition>
<ds-text v-html="submitMessage" />
</ds-card>
</base-card>
<ds-form v-else v-model="form" :schema="formSchema" @submit="submit">
<template slot-scope="{ errors }">
<ds-card :header="$t('settings.email.name')">
<base-card>
<h2 class="title">{{ $t('settings.email.name') }}</h2>
<ds-input
id="email"
model="email"
icon="envelope"
:label="$t('settings.email.labelEmail')"
/>
<template slot="footer">
<ds-space class="backendErrors" v-if="backendErrors">
<ds-text align="center" bold color="danger">{{ backendErrors.message }}</ds-text>
</ds-space>
<base-button icon="check" :disabled="errors" type="submit" filled>
{{ $t('actions.save') }}
</base-button>
</template>
</ds-card>
<ds-space class="backendErrors" v-if="backendErrors">
<ds-text align="center" bold color="danger">{{ backendErrors.message }}</ds-text>
</ds-space>
<base-button icon="check" :disabled="errors" type="submit" filled>
{{ $t('actions.save') }}
</base-button>
</base-card>
</template>
</ds-form>
</template>

View File

@ -1,5 +1,5 @@
<template>
<ds-card>
<base-card>
<transition name="ds-transition-fade">
<client-only>
<sweetalert-icon :icon="sweetAlertIcon" />
@ -34,7 +34,7 @@
</client-only>
</ds-space>
</template>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('settings.organizations.name')">
<base-card>
<h2 class="title">{{ $t('settings.organizations.name') }}</h2>
<hc-empty icon="tasks" message="Coming Soon…" />
</ds-card>
</base-card>
</template>
<script>

View File

@ -6,7 +6,8 @@
@input-valid="handleInputValid"
@submit="handleSubmitSocialMedia"
>
<ds-card :header="$t('settings.social-media.name')">
<base-card>
<h2 class="title">{{ $t('settings.social-media.name') }}</h2>
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
<ds-list>
<ds-list-item v-for="link in socialMediaLinks" :key="link.id" class="list-item--high">
@ -62,7 +63,7 @@
</base-button>
</ds-space>
</ds-space>
</ds-card>
</base-card>
</ds-form>
</template>

View File

@ -1,11 +1,12 @@
<template>
<ds-card :header="$t('settings.privacy.name')">
<base-card>
<h2 class="title">{{ $t('settings.privacy.name') }}</h2>
<ds-space margin-bottom="small">
<input id="allow-shouts" type="checkbox" v-model="shoutsAllowed" />
<label for="allow-shouts">{{ $t('settings.privacy.make-shouts-public') }}</label>
</ds-space>
<base-button filled @click="submit" :disabled="disabled">{{ $t('actions.save') }}</base-button>
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,7 +1,8 @@
<template>
<ds-card :header="$t('settings.security.name')">
<base-card>
<h2 class="title">{{ $t('settings.security.name') }}</h2>
<change-password />
</ds-card>
</base-card>
</template>
<script>

View File

@ -1,27 +1,21 @@
<template>
<ds-container width="medium">
<ds-card icon="balance-scale" :header="$t(`termsAndConditions.newTermsAndConditions`)" centered>
<p>
<nuxt-link :to="{ name: 'terms-and-conditions' }" target="_blank">
<base-button>
{{ $t(`termsAndConditions.termsAndConditionsNewConfirmText`) }}
</base-button>
</nuxt-link>
</p>
<ds-text>
<input id="checkbox" type="checkbox" v-model="checked" :checked="checked" />
<label
for="checkbox"
v-html="$t('termsAndConditions.termsAndConditionsNewConfirm')"
></label>
</ds-text>
<template slot="footer">
<base-button filled @click="submit" :disabled="!checked">
{{ $t(`actions.save`) }}
<ds-container width="medium" class="terms-and-conditions-confirm">
<base-card>
<base-icon name="balance-scale" />
<h2 class="title">{{ $t(`termsAndConditions.newTermsAndConditions`) }}</h2>
<nuxt-link :to="{ name: 'terms-and-conditions' }" target="_blank">
<base-button>
{{ $t(`termsAndConditions.termsAndConditionsNewConfirmText`) }}
</base-button>
</template>
</ds-card>
</nuxt-link>
<label for="checkbox">
<input id="checkbox" type="checkbox" v-model="checked" :checked="checked" />
{{ $t('termsAndConditions.termsAndConditionsNewConfirm') }}
</label>
<base-button filled @click="submit" :disabled="!checked">
{{ $t(`actions.save`) }}
</base-button>
</base-card>
</ds-container>
</template>
@ -91,3 +85,17 @@ export default {
},
}
</script>
<style lang="scss">
.terms-and-conditions-confirm > .base-card {
height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
> .base-icon {
font-size: $font-size-xxx-large;
}
}
</style>