Conversations and other stuff
This commit is contained in:
parent
c0940970c9
commit
1d910927fe
30 changed files with 3468 additions and 35 deletions
|
@ -1,5 +1,5 @@
|
||||||
VUE_APP_APIENDPOINT="/api/"
|
VUE_APP_APIENDPOINT="/api/"
|
||||||
VUE_APP_APIVERSION="v1"
|
VUE_APP_APIVERSION="v1"
|
||||||
VUE_APP_GATEWAYENDPOINT="http://localhost:23981"
|
VUE_APP_GATEWAYENDPOINT="/socket.io/"
|
||||||
VUE_APP_STAGING=true
|
VUE_APP_STAGING=true
|
||||||
VUE_APP_RELEASE="Canary"
|
VUE_APP_RELEASE="Canary"
|
|
@ -1,3 +1,11 @@
|
||||||
.large-icon {
|
.large-icon {
|
||||||
font-size: 60px;
|
font-size: 60px;
|
||||||
}
|
}
|
||||||
|
.vertical {
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
-ms-transform: translate(-50%, -50%);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
|
@ -9,6 +9,7 @@
|
||||||
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales 'src/locales/**/*.json'"
|
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales 'src/locales/**/*.json'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@kangc/v-md-editor": "^1.6.0",
|
||||||
"@vue/cli": "^4.5.10",
|
"@vue/cli": "^4.5.10",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"buefy": "^0.9.4",
|
"buefy": "^0.9.4",
|
||||||
|
@ -17,6 +18,8 @@
|
||||||
"dotenv-webpack": "^6.0.0",
|
"dotenv-webpack": "^6.0.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"socket.io": "^3.1.0",
|
"socket.io": "^3.1.0",
|
||||||
|
"tiptap": "^1.32.0",
|
||||||
|
"tiptap-extensions": "^1.35.0",
|
||||||
"to-boolean": "^1.0.0",
|
"to-boolean": "^1.0.0",
|
||||||
"v-offline": "^1.3.0",
|
"v-offline": "^1.3.0",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import './assets/scss/buefy';
|
@import './assets/scss/buefy';
|
||||||
|
@import './assets/scss/editor';
|
||||||
</style>
|
</style>
|
||||||
<style>
|
<style>
|
||||||
@import 'https://kit-pro.fontawesome.com/releases/v5.15.1/css/pro.min.css';
|
@import 'https://kit-pro.fontawesome.com/releases/v5.15.1/css/pro.min.css';
|
||||||
|
|
222
src/assets/scss/editor.scss
Normal file
222
src/assets/scss/editor.scss
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
$color-black: #000000;
|
||||||
|
$color-white: #ffffff;
|
||||||
|
$color-grey: #dddddd;
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
position: relative;
|
||||||
|
max-width: 30rem;
|
||||||
|
margin: 0 auto 5rem auto;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
* {
|
||||||
|
caret-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: $color-black;
|
||||||
|
color: $color-white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p code {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
background: rgba($color-black, 0.1);
|
||||||
|
color: rgba($color-black, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li > p,
|
||||||
|
li > ol,
|
||||||
|
li > ul {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid rgba($color-black, 0.1);
|
||||||
|
color: rgba($color-black, 0.8);
|
||||||
|
padding-left: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
min-width: 1em;
|
||||||
|
border: 2px solid $color-grey;
|
||||||
|
padding: 3px 5px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
> * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCell:after {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
left: 0; right: 0; top: 0; bottom: 0;
|
||||||
|
background: rgba(200, 200, 255, 0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
z-index: 20;
|
||||||
|
background-color: #adf;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menubar {
|
||||||
|
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
|
||||||
|
|
||||||
|
&.is-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focused {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition: visibility 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button {
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-flex;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: $color-black;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($color-black, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: rgba($color-black, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span#{&}__button {
|
||||||
|
font-size: 13.3333px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menububble {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
z-index: 20;
|
||||||
|
background: $color-black;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, visibility 0.2s;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button {
|
||||||
|
display: inline-flex;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: $color-white;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($color-white, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: rgba($color-white, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
font: inherit;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: $color-white;
|
||||||
|
}
|
||||||
|
}
|
315
src/assets/scss/elementStyles.scss
Normal file
315
src/assets/scss/elementStyles.scss
Normal file
|
@ -0,0 +1,315 @@
|
||||||
|
@import './variables.scss';
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: rgba(245, 245, 245, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: $color__gray--primary;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 700;
|
||||||
|
color: $color__darkgray--darker;
|
||||||
|
position: relative;
|
||||||
|
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII=) repeat-x 100% 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
|
||||||
|
&:hover, &:visited {
|
||||||
|
color: $color__darkgray--primary;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
color: $color__darkgray--darker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
background-color: $color__gray--primary;
|
||||||
|
border-left: thick solid $color__gray--darkest;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b, strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture_circle {
|
||||||
|
border-radius: 100%;
|
||||||
|
background-position: center center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
height: calc(100% - 4px);
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
border: 2px solid rgba(150, 150, 150, 0.25);
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 1.5px solid $color__gray--darkest;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
@include text($font--role-default, 1rem, bold);
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
background: none;
|
||||||
|
background-color: #fff;
|
||||||
|
color: lighten($color__text--primary, 30%) ;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, filter 0.2s;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $color__lightgray--primary;
|
||||||
|
border-color: $color__gray--darkest;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $color__lightgray--darker;
|
||||||
|
border-color: $color__gray--darkest;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-focus-inner { border: 0; }
|
||||||
|
|
||||||
|
@at-root #{&}--borderless {
|
||||||
|
color: $color__text--secondary;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--thin_text {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--margin {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--modal {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--color_input {
|
||||||
|
width: 10rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
height: 31px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--disabled {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@include user-select(none);
|
||||||
|
|
||||||
|
filter: saturate(80%) grayscale(40%) brightness(110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--lightblue {
|
||||||
|
border-color: $color__blue--primary;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
border-color: $color__blue--darker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin filled_button($background, $border, $text: #fff) {
|
||||||
|
background-color: $background;
|
||||||
|
border-color: $border;
|
||||||
|
color: $text ;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: darken($background, 5%);
|
||||||
|
border-color: rgba($border, 0.6);
|
||||||
|
color: darken(#fff, 5%) ;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: darken($background, 10%);
|
||||||
|
border-color: rgba($border, 0.6);
|
||||||
|
color: darken(#fff, 10%) ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}--blue {
|
||||||
|
@include filled_button(
|
||||||
|
$color__blue--primary,
|
||||||
|
$color__blue--darker
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@at-root #{&}--green {
|
||||||
|
@include filled_button($color__green--primary, $color__green--darker);
|
||||||
|
}
|
||||||
|
@at-root #{&}--red {
|
||||||
|
@include filled_button($color__red--primary, $color__red--darker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.button {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: 1.5px solid $color__gray--darkest;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
@include text;
|
||||||
|
padding: 0.25rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h1 {
|
||||||
|
@include text($font--role-default, 3rem);
|
||||||
|
}
|
||||||
|
.h3 {
|
||||||
|
@include text($font--role-default, 1.5rem);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
@at-root #{&}--margin_top {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.p--condensed {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category_widget {
|
||||||
|
@at-root #{&}__box {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: thin solid $color__gray--darker;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__text {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
@at-root #{&}__title {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay_message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
|
transition: none;
|
||||||
|
color: $color__gray--darkest;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: $color__gray--darker;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__loading {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
div.overlay_message {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin_badge {
|
||||||
|
background: $color__darkgray--primary;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #fff;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
|
||||||
|
@at-root #{&}--large {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link_preview {
|
||||||
|
border: thick solid $color__gray--primary;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
h1, h2, p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: normal;
|
||||||
|
color: $color__darkgray--primary;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-weight: 300;
|
||||||
|
display: flex;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100px;
|
||||||
|
max-height: 100px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#{&}__partial {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote.twitter-tweet {
|
||||||
|
padding: 1rem;
|
||||||
|
padding-top: 0;
|
||||||
|
background-color: unset;
|
||||||
|
margin: 0;
|
||||||
|
border: thick solid $color__gray--primary;
|
||||||
|
|
||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Vue transition class
|
||||||
|
.fade-enter-active, .fade-leave-active {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.fade-enter, .fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-active, .slide-leave-active {
|
||||||
|
transition: opacity 0.2s, max-height 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.slide-enter, .slide-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
.slide-enter-to, .slide-leave {
|
||||||
|
max-height: 20rem;
|
||||||
|
}
|
84
src/assets/scss/elements.scss
Normal file
84
src/assets/scss/elements.scss
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
@import './variables.scss';
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: thin solid $gray-2;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: $font-family;
|
||||||
|
font-weight: 300;
|
||||||
|
height: 1.5rem;
|
||||||
|
outline: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
border-color: $gray-3;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
border-color: $gray-2;
|
||||||
|
box-shadow: 0 0 0 2px $gray-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.input--textarea {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: unset;
|
||||||
|
width: unset;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label .input {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background-color: #fff;
|
||||||
|
border: thin solid $gray-2;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: $font-family;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray-hover;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $gray-0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 2px $gray-2;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-focus-inner {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.button--blue {
|
||||||
|
background-color: $blue-2;
|
||||||
|
border-color: $blue-5;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover { background-color: darken($blue-2, 5%) }
|
||||||
|
&:active { background-color: darken($blue-2, 7.5%) }
|
||||||
|
&:focus { box-shadow: 0 0 0 2px $blue-5; }
|
||||||
|
}
|
||||||
|
.button--blue_border {
|
||||||
|
border-color: $blue-5;
|
||||||
|
color: $blue-5;
|
||||||
|
&:focus {
|
||||||
|
border-color: $blue-4;
|
||||||
|
box-shadow: 0 0 0 2px $blue-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.button--red {
|
||||||
|
background-color: $red-1;
|
||||||
|
border-color: $red-5;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover { background-color: darken($red-1, 5%) }
|
||||||
|
&:active { background-color: darken($red-1, 7.5%) }
|
||||||
|
&:focus { box-shadow: 0 0 0 2px $red-5; }
|
||||||
|
}
|
282
src/components/ChatConversationInput.vue
Normal file
282
src/components/ChatConversationInput.vue
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
<template>
|
||||||
|
<div class='new_conversation_input'>
|
||||||
|
<div
|
||||||
|
class='new_conversation_input__input_bar'
|
||||||
|
:class='{ "new_conversation_input__input_bar--focus": inputFocused }'
|
||||||
|
>
|
||||||
|
<div class='new_conversation_input__selected_user' v-for='(user) in selected' :key='"conversation-selected-" + user.id'>
|
||||||
|
{{user.username}}
|
||||||
|
</div>
|
||||||
|
<div class='new_conversation_input__selected_users' v-if="selected.user">
|
||||||
|
</div>
|
||||||
|
<div class='new_conversation_input__placeholders_input'>
|
||||||
|
<input
|
||||||
|
ref='input'
|
||||||
|
v-model='input'
|
||||||
|
placeholder="Enter a username"
|
||||||
|
class='new_conversation_input__input'
|
||||||
|
@keydown='inputHandler'
|
||||||
|
@focus='inputFocused = true;'
|
||||||
|
@blur='inputFocused = false;'
|
||||||
|
/>
|
||||||
|
<div class='new_conversation_input__placeholders'>
|
||||||
|
<span
|
||||||
|
class='new_conversation_input__placeholder--hidden'
|
||||||
|
>
|
||||||
|
{{placeholders[0]}}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class='new_conversation_input__placeholder'
|
||||||
|
v-if='placeholders[1]'
|
||||||
|
>
|
||||||
|
{{placeholders[1]}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<transition name='transition-slide-fade'>
|
||||||
|
<div class='new_conversation_input__suggestions' v-if='suggestions.length'>
|
||||||
|
<div
|
||||||
|
class='new_conversation_input__suggestion_item'
|
||||||
|
:class='{ "new_conversation_input__suggestion_item--focus": focusedSuggestionIndex === $index }'
|
||||||
|
tabindex='0'
|
||||||
|
v-for='(suggestion, $index) in suggestions'
|
||||||
|
:key='"conversation-suggestion-" + suggestion.id'
|
||||||
|
@click='addUser(suggestion)'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class='new_conversation_input__profile_picture'></div>
|
||||||
|
</div>
|
||||||
|
<div class='new_conversation_input__name'>{{suggestion.username}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'new-conversation-input',
|
||||||
|
props: ['value'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
input: '',
|
||||||
|
inputFocused: false,
|
||||||
|
suggestionsData: [],
|
||||||
|
selected: [],
|
||||||
|
focusedSuggestionIndex: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
suggestions () {
|
||||||
|
return this.suggestionsData.filter(user => {
|
||||||
|
return (
|
||||||
|
!this.selected.filter(u => u.id === user.id).length &&
|
||||||
|
user.username !== this.$store.state.username
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedSuggestionIndex () {
|
||||||
|
if(this.focusedSuggestionIndex !== null) {
|
||||||
|
return this.focusedSuggestionIndex;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholders () {
|
||||||
|
if(!this.suggestions.length) {
|
||||||
|
return ['', ''];
|
||||||
|
}
|
||||||
|
let firstSuggestion = this.suggestions[this.selectedSuggestionIndex].username;
|
||||||
|
|
||||||
|
return [
|
||||||
|
firstSuggestion.slice(0, this.input.length),
|
||||||
|
firstSuggestion.slice(this.input.length, firstSuggestion.length)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
inputHandler (e) {
|
||||||
|
//tab, space or enter
|
||||||
|
if([9, 32, 13].indexOf(e.keyCode) > -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addUser();
|
||||||
|
//backspace
|
||||||
|
} else if (
|
||||||
|
e.keyCode === 8 &&
|
||||||
|
!this.input.length &&
|
||||||
|
this.selected.length
|
||||||
|
) {
|
||||||
|
this.input = this.selected.pop().username;
|
||||||
|
//Down
|
||||||
|
} else if(e.keyCode === 40) {
|
||||||
|
this.setSelectionFocus(1);
|
||||||
|
//Up
|
||||||
|
} else if(e.keyCode === 38) {
|
||||||
|
this.setSelectionFocus(-1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setSelectionFocus (direction) {
|
||||||
|
let index = this.focusedSuggestionIndex;
|
||||||
|
let length = this.suggestions.length;
|
||||||
|
|
||||||
|
if(!length) return;
|
||||||
|
|
||||||
|
if(index !== null) {
|
||||||
|
let newIndex = (index + 1*direction) % length;
|
||||||
|
this.focusedSuggestionIndex = (newIndex > 0) ? newIndex : null;
|
||||||
|
} else if (direction === 1 && index === null) {
|
||||||
|
this.focusedSuggestionIndex = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addUser (user) {
|
||||||
|
if(user) {
|
||||||
|
this.selected.push(user);
|
||||||
|
} else if(this.suggestions.length) {
|
||||||
|
this.selected.push(
|
||||||
|
this.suggestions[this.selectedSuggestionIndex]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.input = '';
|
||||||
|
this.focusedSuggestionIndex = null;
|
||||||
|
this.$refs.input.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
input () {
|
||||||
|
let input = this.input.trim();
|
||||||
|
|
||||||
|
if(input) {
|
||||||
|
this.axios
|
||||||
|
.get('/api/v1/kaverti/search/user/?q=' + input)
|
||||||
|
.then(res => {
|
||||||
|
this.suggestionsData = res.data.users;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.suggestionsData = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected () {
|
||||||
|
this.$emit('input', this.selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
@import '../assets/scss/elements.scss';
|
||||||
|
|
||||||
|
.new_conversation_input {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@at-root #{&}__input_bar {
|
||||||
|
@extend .input;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
padding-left: 0.125rem;
|
||||||
|
width: 30rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@at-root #{&}--focus {
|
||||||
|
border-color: $gray-2;
|
||||||
|
box-shadow: 0 0 0 2px $gray-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__selected_users {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-right: 0.125rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__selected_user {
|
||||||
|
background-color: $gray-1;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
margin: 0.125rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__placeholders_input {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@at-root #{&}__input {
|
||||||
|
border: 0;
|
||||||
|
font-family: $font-family;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@at-root #{&}__placeholders {
|
||||||
|
align-items: center;
|
||||||
|
color: $gray-2;
|
||||||
|
display: flex;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: 100%;
|
||||||
|
left: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
@at-root #{&}__placeholder {
|
||||||
|
margin-left: -0.5px;
|
||||||
|
|
||||||
|
@at-root #{&}--hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__suggestions {
|
||||||
|
background-color: #fff;
|
||||||
|
border: thin solid $gray-2;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.25rem 0.25rem rgba(0, 0, 0, 0.2);
|
||||||
|
left: calc(50% - 15rem);
|
||||||
|
max-height: 25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
width: 30rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__suggestion_item {
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
font-weight: 300;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray-hover;
|
||||||
|
}
|
||||||
|
@at-root #{&}--focus {
|
||||||
|
background-color: $gray-0;
|
||||||
|
}
|
||||||
|
&:active, &:focus {
|
||||||
|
background-color: $gray-0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__profile_picture {
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: $gray-1;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
115
src/components/ChatMenu.vue
Normal file
115
src/components/ChatMenu.vue
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<template>
|
||||||
|
<div class='c_menu'>
|
||||||
|
<div
|
||||||
|
class='c_menu__slot'
|
||||||
|
:class='{ "c_menu__slot--show": showMenu }'
|
||||||
|
@click='showMenu = true'
|
||||||
|
ref='slot'
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class='c_menu__menu'
|
||||||
|
:style='{ "left": left, "right": right }'
|
||||||
|
:class='{ "c_menu__menu--show": showMenu }'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class='c_menu__item'
|
||||||
|
:v-for='item in items'
|
||||||
|
@click='showMenu = false; $emit(item.event)'
|
||||||
|
>
|
||||||
|
{{item.text}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class='c_menu__overlay'
|
||||||
|
:class='{ "c_menu__overlay--show": showMenu }'
|
||||||
|
@click='showMenu = false'
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'c-menu',
|
||||||
|
props: ['items'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
left: '0',
|
||||||
|
right: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
let rect = this.$refs.slot.getBoundingClientRect();
|
||||||
|
let rem = 16;
|
||||||
|
|
||||||
|
if(rect.right + 10*rem + 0.5*rem > window.innerWidth) {
|
||||||
|
this.left = null;
|
||||||
|
this.right = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.c_menu {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@at-root #{&}__slot {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__overlay {
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
@at-root #{&}--show {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__menu {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid $gray-2;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.25rem 0.25rem rgba(0, 0, 0, 0.2);
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
transition: margin-top 0.2s, opacity 0.2s;
|
||||||
|
width: 10rem;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
@at-root #{&}--show {
|
||||||
|
margin-top: 0;
|
||||||
|
pointer-events: all;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__item {
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray-hover;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $gray-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
28
src/components/ChatPagination.vue
Normal file
28
src/components/ChatPagination.vue
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<template>
|
||||||
|
<div class='scroll_load' @scroll='handler'>
|
||||||
|
<c-loading-icon v-if='position === "top" && loading'>LOAD TOP</c-loading-icon>
|
||||||
|
<slot></slot>
|
||||||
|
<c-loading-icon v-if='position === "bottom" && loading'>LOAD BOTTOM</c-loading-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'c-scroll-load',
|
||||||
|
props: ['position', 'loading'],
|
||||||
|
methods: {
|
||||||
|
handler (e) {
|
||||||
|
let $el = e.target;
|
||||||
|
let height = $el.scrollHeight - $el.scrollTop - 16 <= $el.clientHeight;
|
||||||
|
|
||||||
|
if(
|
||||||
|
(this.position === 'top' && $el.scrollTop < 16) ||
|
||||||
|
(this.position === 'bottom' && height)
|
||||||
|
) {
|
||||||
|
this.$emit('load');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
41
src/components/ChatUserTyping.vue
Normal file
41
src/components/ChatUserTyping.vue
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<transition name='transition-fade'>
|
||||||
|
<div class='user_typing' v-if='typingUsers.length'>
|
||||||
|
<div class='user_typing__users' v-if='users.length > 2'>{{userList}} {{typingUsers.length > 2 ? "are" : "is"}} typing</div>
|
||||||
|
<c-loading-dots>Loading</c-loading-dots>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'user-typing',
|
||||||
|
props: ['users', 'typing-users'],
|
||||||
|
computed: {
|
||||||
|
userList () {
|
||||||
|
this.typingUsers
|
||||||
|
return this.typingUsers
|
||||||
|
.map(u => u.username)
|
||||||
|
.join(', ')
|
||||||
|
.replace(/, ([^,]*)$/, ' and $1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.user_typing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
@at-root #{&}__users {
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
113
src/components/ConversationMessage.vue
Normal file
113
src/components/ConversationMessage.vue
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class='conversation_message'
|
||||||
|
:class='{
|
||||||
|
"conversation_message--self": self,
|
||||||
|
"conversation_message--margin": useMargin
|
||||||
|
}'
|
||||||
|
>
|
||||||
|
<conversation-time-break :message='message' :previous='adjacentMessages.previous'></conversation-time-break>
|
||||||
|
<div
|
||||||
|
class='conversation_message__username'
|
||||||
|
v-if='showUsername'
|
||||||
|
>
|
||||||
|
{{message.User.username}}
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
class='conversation_message__message'
|
||||||
|
:class='{
|
||||||
|
"conversation_message__message--self": self
|
||||||
|
}'
|
||||||
|
>{{message.content}}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ConversationTimeBreak from './ConversationTimeBreak';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'conversation-message',
|
||||||
|
props: ['message', 'users', 'context'],
|
||||||
|
components: {
|
||||||
|
ConversationTimeBreak
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
self () {
|
||||||
|
return this.message.User.username === this.$store.state.username;
|
||||||
|
},
|
||||||
|
showUsername () {
|
||||||
|
let prev = this.adjacentMessages.previous;
|
||||||
|
let selfUsername = this.message.User.username;
|
||||||
|
|
||||||
|
//If first message or not from the same user
|
||||||
|
//and there are more than two users in the conversation
|
||||||
|
return (
|
||||||
|
(!prev || prev.User.username !== selfUsername) &&
|
||||||
|
(this.users.length > 2 && !this.self)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
useMargin () {
|
||||||
|
let next = this.context[1];
|
||||||
|
|
||||||
|
//If next message exists and is not from the same user
|
||||||
|
return (
|
||||||
|
next &&
|
||||||
|
next.User.username !== this.message.User.username
|
||||||
|
);
|
||||||
|
},
|
||||||
|
adjacentMessages () {
|
||||||
|
let currentIndex = this.context.findIndex(m => m.id === this.message.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous: this.context[currentIndex-1],
|
||||||
|
next: this.context[currentIndex+1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.conversation_message {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
|
||||||
|
@at-root #{&}--self {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--margin {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__username {
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__message {
|
||||||
|
background-color: $gray-0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-family: $font-family;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 300;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 75%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
white-space: pre-line;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
@at-root #{&}--self {
|
||||||
|
background-color: $blue-2;
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
53
src/components/ConversationTimeBreak.vue
Normal file
53
src/components/ConversationTimeBreak.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<div class='conversation_time_break' v-if='showDate'>
|
||||||
|
{{formattedDate}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'conversation-time-break',
|
||||||
|
props: ['message', 'previous'],
|
||||||
|
computed: {
|
||||||
|
formattedDate () {
|
||||||
|
let date = new Date(this.message.createdAt);
|
||||||
|
|
||||||
|
let beginningOfToday = new Date();beginningOfToday.setMilliseconds(0);beginningOfToday.setSeconds(0);beginningOfToday.setMinutes(0);beginningOfToday.setHours(0);
|
||||||
|
let beginningOfYesterday = new Date(beginningOfToday - 24*60*60*1000);
|
||||||
|
let timeString = date.toTimeString().slice(0, 5);
|
||||||
|
|
||||||
|
if(date - beginningOfToday >= 0) {
|
||||||
|
return timeString;
|
||||||
|
} else if(date - beginningOfYesterday >= 0) {
|
||||||
|
return 'Yesterday at ' + timeString;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString() + ' at ' + timeString;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showDate () {
|
||||||
|
if(!this.previous) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
let date = new Date(this.message.createdAt);
|
||||||
|
let prev = new Date(this.previous.createdAt);
|
||||||
|
|
||||||
|
//Greater than 30 minutes difference
|
||||||
|
return !prev || (date - prev) > 1000*60*30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.conversation_time_break {
|
||||||
|
color: $text-secondary;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
13
src/components/Editor.vue
Normal file
13
src/components/Editor.vue
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "Editor"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
67
src/components/EditorIcons.vue
Normal file
67
src/components/EditorIcons.vue
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<template>
|
||||||
|
<div class="icon" :class="[`icon--${name}`, `icon--${size}`, { 'has-align-fix': fixAlign }]">
|
||||||
|
<svg class="icon__svg">
|
||||||
|
<use xmlns:xlink="http://www.w3.org/1999/xlink" :xlink:href="'#icon--' + name"></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {},
|
||||||
|
size: {
|
||||||
|
default: 'normal',
|
||||||
|
},
|
||||||
|
modifier: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
fixAlign: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
margin: 0 .3rem;
|
||||||
|
top: -.05rem;
|
||||||
|
fill: currentColor;
|
||||||
|
// &.has-align-fix {
|
||||||
|
// top: -.1rem;
|
||||||
|
// }
|
||||||
|
&__svg {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// svg sprite
|
||||||
|
body > svg,
|
||||||
|
.icon use > svg,
|
||||||
|
symbol {
|
||||||
|
path,
|
||||||
|
rect,
|
||||||
|
circle,
|
||||||
|
g {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
*[d="M0 0h24v24H0z"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -224,7 +224,7 @@
|
||||||
required>
|
required>
|
||||||
</b-input>
|
</b-input>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-checkbox v-model="register.agree">{{$t('register.agree')}} <router-link to="/legal/tos">{{$t('tos')}}</router-link></b-checkbox>
|
<b-checkbox v-model="register.agree">{{$t('register.agree')}} <router-link @click="registerModal = false " to="/legal/tos">{{$t('tos')}}</router-link></b-checkbox>
|
||||||
</section>
|
</section>
|
||||||
<footer class="modal-card-foot">
|
<footer class="modal-card-foot">
|
||||||
<b-button
|
<b-button
|
||||||
|
|
236
src/components/SidebarChat.vue
Normal file
236
src/components/SidebarChat.vue
Normal file
|
@ -0,0 +1,236 @@
|
||||||
|
<template>
|
||||||
|
<div class=''>
|
||||||
|
<div class='side_panel__header'>
|
||||||
|
<c-menu
|
||||||
|
class='side_panel__username'
|
||||||
|
:items='userMenu'
|
||||||
|
@logout='logout'
|
||||||
|
>
|
||||||
|
{{$store.state.username}} <font-awesome-icon icon='angle-down'></font-awesome-icon>
|
||||||
|
</c-menu>
|
||||||
|
<b-button
|
||||||
|
class='side_panel__add button button--blue_border'
|
||||||
|
@click='$router.push("/chat/conversation")'
|
||||||
|
>
|
||||||
|
New conversation
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class='side_panel__search'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Search conversations and people'
|
||||||
|
class='input side_panel__search__input'
|
||||||
|
:class='{
|
||||||
|
"side_panel__search__input--small": showCloseButton
|
||||||
|
}'
|
||||||
|
@focus='setShowCloseButton(true)'
|
||||||
|
@keyup='getConversations'
|
||||||
|
v-model='searchQuery'
|
||||||
|
>
|
||||||
|
<transition name='transition-grow'>
|
||||||
|
<button
|
||||||
|
class='button side_panel__search__close'
|
||||||
|
v-if='showCloseButton'
|
||||||
|
@click='setShowCloseButton(false)'
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
<c-scroll-load
|
||||||
|
class='side_panel__conversations'
|
||||||
|
:class='{ "side_panel__conversations--empty": !$store.state.user.conversations.length }'
|
||||||
|
|
||||||
|
:loading='loading'
|
||||||
|
position='bottom'
|
||||||
|
@load='getConversations'
|
||||||
|
>
|
||||||
|
<side-panel-conversation
|
||||||
|
:v-for='conversation in $store.state.user.conversations'
|
||||||
|
:conversation='conversation'
|
||||||
|
tabindex='0'
|
||||||
|
></side-panel-conversation>
|
||||||
|
|
||||||
|
<div v-if='!$store.state.user.conversations.length && !loading'>
|
||||||
|
No conversations
|
||||||
|
</div>
|
||||||
|
</c-scroll-load>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CMenu from './ChatMenu';
|
||||||
|
import CScrollLoad from './ChatPagination';
|
||||||
|
import SidePanelConversation from './SidebarChatComp';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'side-panel',
|
||||||
|
components: {
|
||||||
|
CMenu,
|
||||||
|
CScrollLoad,
|
||||||
|
SidePanelConversation
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
page: 0,
|
||||||
|
|
||||||
|
loading: false,
|
||||||
|
userMenu: [
|
||||||
|
{ text: 'Settings', event: 'settings' },
|
||||||
|
{ text: 'Log out', event: 'logout' }
|
||||||
|
],
|
||||||
|
|
||||||
|
showCloseButton: false,
|
||||||
|
searchQuery: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
//When typing for each letter clear the data
|
||||||
|
searchQuery () {
|
||||||
|
this.page = 0;
|
||||||
|
this.$store.commit('clearConversations');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
logout () {
|
||||||
|
this.axios
|
||||||
|
.post('/api/user/logout')
|
||||||
|
.then(() => {
|
||||||
|
this.$store.commit('setUser', { id: null, username: null });
|
||||||
|
this.$router.push('/');
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setShowCloseButton (val) {
|
||||||
|
if(this.showCloseButton !== val) {
|
||||||
|
this.showCloseButton = val;
|
||||||
|
this.$store.commit('clearConversations');
|
||||||
|
this.page = 0;
|
||||||
|
|
||||||
|
if(!val) {
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.getConversations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getConversations () {
|
||||||
|
if(!this.loading && this.page !== null) {
|
||||||
|
let params = { page: this.page };
|
||||||
|
let searchQuery = this.searchQuery.trim();
|
||||||
|
if(searchQuery) params.search = searchQuery;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
this.axios
|
||||||
|
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `users/conversations`, { params })
|
||||||
|
.then(res => {
|
||||||
|
this.loading = false;
|
||||||
|
this.$store.commit('addConversations', res.data.Conversations);
|
||||||
|
this.page = res.data.continuePagination ? this.page+1 : null;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.loading = false;
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateConversations (updatedConversation) {
|
||||||
|
//If conversation is already open, assume message has been read
|
||||||
|
if(
|
||||||
|
this.$route.name === 'conversation' &&
|
||||||
|
this.$route.params.id &&
|
||||||
|
+this.$route.params.id === updatedConversation.id
|
||||||
|
) {
|
||||||
|
updatedConversation.lastRead = new Date() + '';
|
||||||
|
}
|
||||||
|
this.$store.commit('updateUnshiftConversation', updatedConversation);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
this.getConversations();
|
||||||
|
this.$io.on('conversation', this.updateConversations);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.side_panel {
|
||||||
|
border-right: thin solid $gray-1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 4rem 2.75rem auto;
|
||||||
|
height: 100%;
|
||||||
|
width: 17rem;
|
||||||
|
|
||||||
|
@at-root #{&}__username {
|
||||||
|
svg {
|
||||||
|
color: $gray-5;
|
||||||
|
position: relative;
|
||||||
|
top: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__header {
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
font-weight: 300;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@at-root #{&}__username {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $gray-4;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: -0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__add {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__search {
|
||||||
|
align-self: start;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto min-content;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
|
||||||
|
@at-root #{&}__input {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
transition: padding 0.2s;
|
||||||
|
|
||||||
|
@at-root #{&}--small {
|
||||||
|
padding: 1rem 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__close {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__conversations {
|
||||||
|
align-self: start;
|
||||||
|
//border-top: thin solid $gray-1;
|
||||||
|
max-height: 100%;
|
||||||
|
padding: 0.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
@at-root #{&}--empty {
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
212
src/components/SidebarChatComp.vue
Normal file
212
src/components/SidebarChatComp.vue
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class='side_panel_conversation'
|
||||||
|
:class='{
|
||||||
|
"side_panel_conversation--selected": selected,
|
||||||
|
"side_panel_conversation--unread": unread
|
||||||
|
}'
|
||||||
|
@click='goToConversation'
|
||||||
|
@keydown.enter='goToConversation'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class='side_panel_conversation__profile_picture'>
|
||||||
|
<div
|
||||||
|
class='side_panel_conversation__profile_picture__letter'
|
||||||
|
:v-for='userLetter in userLetters'
|
||||||
|
:class='[
|
||||||
|
"side_panel_conversation__profile_picture__letter--" + userLetters.length
|
||||||
|
]'
|
||||||
|
:style='{
|
||||||
|
"background-color": userLetter.color
|
||||||
|
}'
|
||||||
|
>
|
||||||
|
{{userLetter.letter}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='side_panel_conversation__conversation_content'>
|
||||||
|
<div class='side_panel_conversation__name'>{{conversation.name}}</div>
|
||||||
|
<div class='side_panel_conversation__snippet'>
|
||||||
|
{{username}}:
|
||||||
|
{{conversation.Messages[0].content}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'side-panel-conversation',
|
||||||
|
props: ['conversation'],
|
||||||
|
computed: {
|
||||||
|
selected () {
|
||||||
|
return (
|
||||||
|
this.$route.name === 'conversation' &&
|
||||||
|
+this.$route.params.id === this.conversation.id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
unread () {
|
||||||
|
return (
|
||||||
|
new Date(this.conversation.lastRead) - new Date(this.conversation.Messages[0].createdAt) < 0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
username () {
|
||||||
|
let username = this.conversation.Messages[0].User.username;
|
||||||
|
|
||||||
|
if(username === this.$store.state.username) {
|
||||||
|
return 'You';
|
||||||
|
} else {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userLetters () {
|
||||||
|
return this.conversation.Users
|
||||||
|
.filter(u => u.username !== this.$store.state.username)
|
||||||
|
.map(u => {
|
||||||
|
return {
|
||||||
|
letter: u.username[0].toUpperCase(),
|
||||||
|
color: u.color
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.slice(0, 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goToConversation () {
|
||||||
|
this.$router.push("/chat/" + this.conversation.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.side_panel_conversation {
|
||||||
|
background-color: #fff;
|
||||||
|
//border-bottom: thin solid $gray-1;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
height: 5rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: $gray-hover;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background-color: $gray-3;
|
||||||
|
border-radius: 0.25rem 0 0 0.25rem;
|
||||||
|
content: '';
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
width: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--selected {
|
||||||
|
background-color: $gray-0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: $gray-0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}--unread {
|
||||||
|
background-color: transparentize($blue-1, 0.7);
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
&:hover { background-color: transparentize($blue-1, 0.7); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root #{&}__profile_picture {
|
||||||
|
background-color: $gray-1;
|
||||||
|
border-radius: 100%;
|
||||||
|
height: 3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 3rem;
|
||||||
|
|
||||||
|
@at-root #{&}__letter {
|
||||||
|
font-weight: 300;
|
||||||
|
padding: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@at-root #{&}--1, #{&}--2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
height: 3rem;
|
||||||
|
line-height: 2.5rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}--2 {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}--3 {
|
||||||
|
height: 1.5rem;
|
||||||
|
|
||||||
|
&:nth-child(1), &:nth-child(2) {
|
||||||
|
display: inline-block;
|
||||||
|
padding-bottom: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
padding-top: 0;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}--4 {
|
||||||
|
display: inline-block;
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
|
||||||
|
&:nth-child(1), &:nth-child(2) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
&:nth-child(3), &:nth-child(4) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
&:nth-child(1), &:nth-child(3) {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
&:nth-child(2), &:nth-child(4) {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__name {
|
||||||
|
display: block;
|
||||||
|
max-height: 1.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 10.25rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__conversation_content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__snippet {
|
||||||
|
color: $text-secondary;
|
||||||
|
font-weight: 300;
|
||||||
|
height: 2.5rem;
|
||||||
|
overflow-y: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -186,7 +186,8 @@
|
||||||
"text": "Modify user badges"
|
"text": "Modify user badges"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"globalWall": "Global Wall"
|
"globalWall": "Global Wall",
|
||||||
|
"news": "Kaverti News"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
|
|
40
src/main.js
40
src/main.js
|
@ -7,6 +7,46 @@ import axios from 'axios'
|
||||||
import VueAxios from 'vue-axios'
|
import VueAxios from 'vue-axios'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
import VMdEditor from '@kangc/v-md-editor';
|
||||||
|
import '@kangc/v-md-editor/lib/style/base-editor.css';
|
||||||
|
import enUS from '@kangc/v-md-editor/lib/lang/en-US';
|
||||||
|
import createHljsTheme from '@kangc/v-md-editor/lib/theme/hljs';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
const hljsTheme = createHljsTheme();
|
||||||
|
import io from 'socket.io-client'
|
||||||
|
import VueSocketIO from "vue-socket.io";
|
||||||
|
Vue.use(
|
||||||
|
new VueSocketIO({
|
||||||
|
debug: true,
|
||||||
|
connection: io(process.env.VUE_APP_GATEWAYENDPOINT), // options object is Optional
|
||||||
|
})
|
||||||
|
);
|
||||||
|
Vue.use(VMdEditor)
|
||||||
|
VMdEditor.lang.use('en-US', enUS);
|
||||||
|
hljsTheme.extend((md, hljs) => {
|
||||||
|
md.set({
|
||||||
|
html: false, // Enable HTML tags in source
|
||||||
|
xhtmlOut: false, // Use '/' to close single tags (<br />).
|
||||||
|
// This is only for full CommonMark compatibility.
|
||||||
|
breaks: true, // Convert '\n' in paragraphs into <br>
|
||||||
|
langPrefix: 'language-', // CSS language prefix for fenced blocks. Can be
|
||||||
|
// useful for external highlighters.
|
||||||
|
linkify: true, // Autoconvert URL-like text to links
|
||||||
|
image: false,
|
||||||
|
|
||||||
|
// Enable some language-neutral replacement + quotes beautification
|
||||||
|
typographer: true,
|
||||||
|
|
||||||
|
// Double + single quotes replacement pairs, when typographer enabled,
|
||||||
|
// and smartquotes on. Could be either a String or an Array.
|
||||||
|
//
|
||||||
|
// For example, you can use '«»„“' for Russian, '„“‚‘' for German,
|
||||||
|
// and ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'] for French (including nbsp).
|
||||||
|
quotes: '“”‘’'
|
||||||
|
})
|
||||||
|
md.disable('image')
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
});
|
||||||
Vue.use(VueAxios, axios)
|
Vue.use(VueAxios, axios)
|
||||||
Vue.use(Buefy)
|
Vue.use(Buefy)
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
|
|
|
@ -51,6 +51,17 @@ const routes = [
|
||||||
{ path: 'friends', component: route('UserFriends') },
|
{ path: 'friends', component: route('UserFriends') },
|
||||||
{ path: 'awards', component: route('UserAwards') }
|
{ path: 'awards', component: route('UserAwards') }
|
||||||
] },
|
] },
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
component: route('Chat'),
|
||||||
|
redirect: ('/chat/home'),
|
||||||
|
children: [
|
||||||
|
{ path: '/', component: route('ChatHome') },
|
||||||
|
{ path: '/home', component: route('ChatHome') },
|
||||||
|
{ path: 'conversation', component: route('ChatMessage') },
|
||||||
|
{ path: 'conversation/:id', component: route('ChatMessage'), name: 'conversation' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/awards',
|
path: '/awards',
|
||||||
name: 'Awards',
|
name: 'Awards',
|
||||||
|
|
|
@ -46,7 +46,8 @@ export default new Vuex.Store({
|
||||||
modeler: false,
|
modeler: false,
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
executive: false,
|
executive: false,
|
||||||
description: ''
|
description: '',
|
||||||
|
conversations: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
@ -131,6 +132,43 @@ export default new Vuex.Store({
|
||||||
},
|
},
|
||||||
brokenRoute(state, value) {
|
brokenRoute(state, value) {
|
||||||
state.enableBrokenRoutes = value
|
state.enableBrokenRoutes = value
|
||||||
|
},
|
||||||
|
clearConversations (state) {
|
||||||
|
state.user.conversations = [];
|
||||||
|
},
|
||||||
|
addConversations (state, conversations) {
|
||||||
|
state.user.conversations.push(...conversations);
|
||||||
|
},
|
||||||
|
updateConversationLastRead (state, id) {
|
||||||
|
let index = state.user.conversations.findIndex(conversation => {
|
||||||
|
return conversation.id === id;
|
||||||
|
});
|
||||||
|
|
||||||
|
let conversation = state.user.conversations[index];
|
||||||
|
conversation.lastRead = new Date() + '';
|
||||||
|
|
||||||
|
state.user.conversations.splice(index, 1, conversation);
|
||||||
|
},
|
||||||
|
updateUnshiftConversation (state, updatedConversation) {
|
||||||
|
let index = state.user.conversations.findIndex(conversation => {
|
||||||
|
return conversation.id === updatedConversation.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(index > -1) {
|
||||||
|
state.user.conversations.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.user.conversations.unshift(updatedConversation);
|
||||||
|
},
|
||||||
|
updateConversationName (state, { id, name }) {
|
||||||
|
let index = state.user.conversations.findIndex(conversation => {
|
||||||
|
return conversation.id === id;
|
||||||
|
});
|
||||||
|
|
||||||
|
let conversation = state.user.conversations[index];
|
||||||
|
conversation.name = name;
|
||||||
|
|
||||||
|
state.user.conversations.splice(index, 1, conversation);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
29
src/views/Chat.vue
Normal file
29
src/views/Chat.vue
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<main class="section">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<SidebarChat></SidebarChat>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-10">
|
||||||
|
<div class="box">
|
||||||
|
<router-view></router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import SidebarChat from "@/components/SidebarChat";
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SidebarChat
|
||||||
|
},
|
||||||
|
name: "Chat"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
27
src/views/ChatHome.vue
Normal file
27
src/views/ChatHome.vue
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<div class="hero is-large is-large">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<i class="fas fa-comment-lines large-icon"></i>
|
||||||
|
<h1 class="title">
|
||||||
|
Kaverti Messaging
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
Chat with anyone on the Kaverti platform, all you need is their username!
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "ChatHome"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
434
src/views/ChatMessage.vue
Normal file
434
src/views/ChatMessage.vue
Normal file
|
@ -0,0 +1,434 @@
|
||||||
|
<template>
|
||||||
|
<div class='conversation'>
|
||||||
|
<b-modal
|
||||||
|
v-model='showDeleteModal'
|
||||||
|
button-text='OK, delete'
|
||||||
|
color='red'
|
||||||
|
@confirm='alert'
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this conversation?
|
||||||
|
</b-modal>
|
||||||
|
|
||||||
|
<b-modal
|
||||||
|
v-model='showEditModal'
|
||||||
|
button-text='Change name'
|
||||||
|
color='blue'
|
||||||
|
@confirm='saveEditModalModelName'
|
||||||
|
>
|
||||||
|
Enter the new name for your chat below
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Chat name'
|
||||||
|
class='input'
|
||||||
|
style='margin-top: 0.5rem;'
|
||||||
|
v-model='editModalModel'
|
||||||
|
/>
|
||||||
|
</b-modal>
|
||||||
|
|
||||||
|
<transition name='transition-fade' mode='out-in'>
|
||||||
|
<div
|
||||||
|
class='conversation__header'
|
||||||
|
key='new-conversation'
|
||||||
|
v-if='showNewConversationBar'
|
||||||
|
>
|
||||||
|
<new-conversation-input
|
||||||
|
class='conversation__new_conversation_input'
|
||||||
|
@input='selected => { newConversationUsers = selected }'
|
||||||
|
></new-conversation-input>
|
||||||
|
</div>
|
||||||
|
<div class='conversation__header' key='header' v-else>
|
||||||
|
<div class='conversation__title'>{{name}}</div>
|
||||||
|
<div class='conversation__actions'>
|
||||||
|
<c-menu
|
||||||
|
:items='settingsItems'
|
||||||
|
@delete='showDeleteModal = true'
|
||||||
|
@edit='showEditModal = true'
|
||||||
|
>Settings</c-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<c-scroll-load
|
||||||
|
class='conversation__main'
|
||||||
|
position='top'
|
||||||
|
:loading='loading'
|
||||||
|
@load='getConversation'
|
||||||
|
|
||||||
|
ref='conversation'
|
||||||
|
>
|
||||||
|
<div class='conversation__main__conversations'>
|
||||||
|
<conversation-message
|
||||||
|
:v-for='message in messages'
|
||||||
|
:context='messages'
|
||||||
|
:message='message'
|
||||||
|
:users='users'
|
||||||
|
></conversation-message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<user-typing :users='users' :typing-users='typingUsers'></user-typing>
|
||||||
|
|
||||||
|
<div style='padding: 2.5rem'></div>
|
||||||
|
</c-scroll-load>
|
||||||
|
|
||||||
|
|
||||||
|
<div class='conversation__input_bar input'>
|
||||||
|
<textarea
|
||||||
|
class='input--textarea conversation__input'
|
||||||
|
placeholder='Type your message here'
|
||||||
|
@keydown.enter.prevent='() => $route.params.id ? sendMessage() : createConversation()'
|
||||||
|
@keydown='sendTyping'
|
||||||
|
v-model='input'
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class='conversation__submit button button--blue'
|
||||||
|
@click='() => $route.params.id ? sendMessage() : createConversation()'
|
||||||
|
>
|
||||||
|
<font-awesome-icon icon='paper-plane'></font-awesome-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CMenu from '../components/ChatMenu';
|
||||||
|
import ConversationMessage from '../components/ConversationMessage';
|
||||||
|
import ConversationTimeBreak from '../components/ConversationTimeBreak';
|
||||||
|
import CScrollLoad from '../components/ChatPagination';
|
||||||
|
import NewConversationInput from '../components/ChatConversationInput';
|
||||||
|
import UserTyping from '../components/ChatUserTyping';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'conversation',
|
||||||
|
components: {
|
||||||
|
CMenu,
|
||||||
|
ConversationMessage,
|
||||||
|
// eslint-disable-next-line vue/no-unused-components
|
||||||
|
ConversationTimeBreak,
|
||||||
|
CScrollLoad,
|
||||||
|
NewConversationInput,
|
||||||
|
UserTyping
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
messages: [],
|
||||||
|
users: [],
|
||||||
|
input: '',
|
||||||
|
page: 1,
|
||||||
|
|
||||||
|
typingUsers: [],
|
||||||
|
typingInterval: null,
|
||||||
|
typingTimer: null,
|
||||||
|
|
||||||
|
newConversationUsers: [],
|
||||||
|
|
||||||
|
settingsItems: [
|
||||||
|
{ text: 'Delete', event: 'delete' },
|
||||||
|
{ text: 'Edit chat name', event: 'edit' }
|
||||||
|
],
|
||||||
|
|
||||||
|
showDeleteModal: false,
|
||||||
|
|
||||||
|
showEditModal: false,
|
||||||
|
editModalModel: '',
|
||||||
|
|
||||||
|
showNewConversationBar: !this.$route.params.id,
|
||||||
|
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
alert () {
|
||||||
|
console.log('Confirm')
|
||||||
|
},
|
||||||
|
saveEditModalModelName () {
|
||||||
|
let name = this.editModalModel.trim();
|
||||||
|
|
||||||
|
if(name.length) {
|
||||||
|
this.axios
|
||||||
|
.put(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `chat/conversation/${this.$route.params.id}/name`, { name })
|
||||||
|
.then(res => {
|
||||||
|
this.name = name;
|
||||||
|
res
|
||||||
|
this.$store.commit(
|
||||||
|
'updateConversationName',
|
||||||
|
{ id: +this.$route.params.id, name }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editModalModel = '';
|
||||||
|
},
|
||||||
|
clearData () {
|
||||||
|
this.name = '';
|
||||||
|
this.messages = [];
|
||||||
|
this.page = 1;
|
||||||
|
this.newConversationUsers = [];
|
||||||
|
|
||||||
|
this.$socket.emit('leaveConversation', {
|
||||||
|
conversationId: +this.$route.params.id || 0
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hasConversationGotScrollbar () {
|
||||||
|
let $el = this.$refs.conversation.$el;
|
||||||
|
|
||||||
|
return $el.scrollHeight > $el.clientHeight;
|
||||||
|
},
|
||||||
|
getConversation () {
|
||||||
|
//If there all pages have been loaded or
|
||||||
|
//a new page is currently loading
|
||||||
|
//then do not send off another request
|
||||||
|
if(!this.$route.params.id || this.page === null || this.loading) return;
|
||||||
|
|
||||||
|
|
||||||
|
this.showNewConversationBar = false;
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
this.axios
|
||||||
|
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `chat/conversation/${this.$route.params.id}?page=${this.page}`)
|
||||||
|
.then(res => {
|
||||||
|
this.loading = false;
|
||||||
|
this.showModal = false;
|
||||||
|
|
||||||
|
this.users = res.data.Users;
|
||||||
|
this.name = res.data.name;
|
||||||
|
this.page= res.data.continuePagination ? this.page + 1 : null;
|
||||||
|
|
||||||
|
let $conversation = this.$refs.conversation.$el;
|
||||||
|
let scrollBottom = $conversation.scrollHeight - $conversation.scrollTop;
|
||||||
|
|
||||||
|
let ids = this.messages.map(m => m.id);
|
||||||
|
let uniqueMessages = res.data.Messages.filter(message => {
|
||||||
|
return !ids.includes(message.id);
|
||||||
|
});
|
||||||
|
this.messages.unshift(...uniqueMessages);
|
||||||
|
|
||||||
|
//Scroll back to original position before new messages were added
|
||||||
|
this.$nextTick(() => {
|
||||||
|
$conversation.scrollTop = $conversation.scrollHeight - scrollBottom;
|
||||||
|
});
|
||||||
|
|
||||||
|
//Keep loading conversations until there is a scroll bar
|
||||||
|
//To enable the scroll load mechanism
|
||||||
|
if(!this.hasConversationGotScrollbar()) {
|
||||||
|
this.getConversation();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.loading = false;
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createConversation () {
|
||||||
|
let userIds = this.newConversationUsers.map(user => user.id);
|
||||||
|
userIds.push(+this.$store.state.user.id);
|
||||||
|
|
||||||
|
//If there is no message or only one user (themselves)
|
||||||
|
//then do not create new conversation
|
||||||
|
if(!this.input.trim().length || userIds.length < 2) return;
|
||||||
|
|
||||||
|
this.axios
|
||||||
|
.post('/api/v1/chat/conversation', { userIds })
|
||||||
|
.then(res => {
|
||||||
|
this.name = res.data.name;
|
||||||
|
this.$router.push({
|
||||||
|
name: 'conversation',
|
||||||
|
params: { id: res.data.id }
|
||||||
|
});
|
||||||
|
this.sendMessage();
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateLastRead () {
|
||||||
|
//this.axios.put('/api/v1/chat/conversation/' + this.$route.params.id);
|
||||||
|
|
||||||
|
//Conversation panel might not have loaded request, so try again in 200 msec
|
||||||
|
if(!this.$store.state.user.conversations.length) {
|
||||||
|
setTimeout(this.updateLastRead, 200);
|
||||||
|
} else {
|
||||||
|
this.$store.commit('updateConversationLastRead', +this.$route.params.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendMessage () {
|
||||||
|
if(!this.input.trim().length) return;
|
||||||
|
|
||||||
|
this.$socket.emit('stopTyping', {
|
||||||
|
conversationId: +this.$route.params.id
|
||||||
|
});
|
||||||
|
|
||||||
|
this.axios
|
||||||
|
.post('/api/v1/chat/message', {
|
||||||
|
content: this.input.trim(),
|
||||||
|
conversationId: +this.$route.params.id
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
res
|
||||||
|
this.input = '';
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.$store.commit('setErrors', e.response.data.errors);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setTypingTimer () {
|
||||||
|
this.typingTimer = setTimeout(() => {
|
||||||
|
this.typingInterval = null;
|
||||||
|
|
||||||
|
this.$socket.emit('stopTyping', {
|
||||||
|
conversationId: +this.$route.params.id
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
sendTyping (e) {
|
||||||
|
//Ignore enter keypress or if no conversation created yet
|
||||||
|
if(e.keyCode === 13 || !this.$route.params.id) return;
|
||||||
|
|
||||||
|
//if interval does not exist --> send startTyping, start timer
|
||||||
|
if(this.typingInterval === null) {
|
||||||
|
this.$socket.emit('startTyping', {
|
||||||
|
conversationId: +this.$route.params.id
|
||||||
|
});
|
||||||
|
|
||||||
|
this.typingInterval = new Date();
|
||||||
|
this.setTypingTimer();
|
||||||
|
//if interval is less than 2 seconds --> clear timer
|
||||||
|
} else if (new Date() - this.typingInterval < 2000) {
|
||||||
|
clearTimeout(this.typingTimer);
|
||||||
|
this.setTypingTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollToBottom (scrollIfNotAtBottom) {
|
||||||
|
let $conversation = this.$refs.conversation.$el;
|
||||||
|
|
||||||
|
//If currently scorlled to bottom or parameter set to true
|
||||||
|
if(
|
||||||
|
scrollIfNotAtBottom ||
|
||||||
|
$conversation.scrollHeight - $conversation.scrollTop === $conversation.clientHeight
|
||||||
|
) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
$conversation.scrollTop = $conversation.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pageLoad () {
|
||||||
|
this.clearData();
|
||||||
|
|
||||||
|
if(this.$route.params.id) {
|
||||||
|
this.$socket.emit('joinConversation', {
|
||||||
|
conversationId: +this.$route.params.id
|
||||||
|
});
|
||||||
|
this.getConversation();
|
||||||
|
this.updateLastRead();
|
||||||
|
} else {
|
||||||
|
this.showNewConversationBar = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.params': 'pageLoad'
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
this.pageLoad();
|
||||||
|
|
||||||
|
this.$io.on('message', message => {
|
||||||
|
if(message.ConversationId !== +this.$route.params.id) return;
|
||||||
|
|
||||||
|
this.messages.push(message);
|
||||||
|
this.scrollToBottom();
|
||||||
|
this.updateLastRead();
|
||||||
|
});
|
||||||
|
this.$io.on('startTyping', ({ userId }) => {
|
||||||
|
let user = this.users.find(u => u.id === userId);
|
||||||
|
this.typingUsers.push(user);
|
||||||
|
this.scrollToBottom();
|
||||||
|
});
|
||||||
|
this.$io.on('stopTyping', ({ userId }) => {
|
||||||
|
let index = this.typingUsers.findIndex(u => u.id === userId);
|
||||||
|
this.typingUsers.splice(index, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss' scoped>
|
||||||
|
@import '../assets/scss/variables';
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 3rem auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@at-root #{&}__header {
|
||||||
|
border-bottom: thin solid $gray-1;
|
||||||
|
display: grid;
|
||||||
|
grid-column-gap: 0.5rem;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
padding: 0rem 2rem;
|
||||||
|
}
|
||||||
|
@at-root #{&}__title, #{&}__new_conversation_input {
|
||||||
|
align-self: center;
|
||||||
|
font-weight: bold;
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
@at-root #{&}__actions {
|
||||||
|
align-self: center;
|
||||||
|
font-weight: 300;
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
@at-root #{&}__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
|
||||||
|
@at-root #{&}__conversations {
|
||||||
|
align-content: flex-end;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@at-root #{&}__input_bar {
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 5px 6px 0px $gray-1;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
height: unset;
|
||||||
|
margin: 0 2rem;
|
||||||
|
padding: 0rem;
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - 4rem);
|
||||||
|
}
|
||||||
|
@at-root #{&}__input {
|
||||||
|
border: 0;
|
||||||
|
font-family: $font-family;
|
||||||
|
height: 3.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@at-root #{&}__submit {
|
||||||
|
border-radius: 100%;
|
||||||
|
box-shadow: 0 4px 6px rgba($blue-5, 0.25);
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
width: 2rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
left: -0.1rem;
|
||||||
|
position: relative;
|
||||||
|
top: -0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,8 +1,144 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main class="section">
|
||||||
<div class="section">
|
<b-modal v-model="postModal">
|
||||||
<h1 class="title">{{thread.name}}</h1>
|
<div class="editor">
|
||||||
<div class="column is-9" v-for='(post) in thread.Posts' :key='"threadPost-" + post.id'>
|
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive }">
|
||||||
|
<div class="menubar">
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.bold() }"
|
||||||
|
@click="commands.bold"
|
||||||
|
>
|
||||||
|
<icon name="bold" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.italic() }"
|
||||||
|
@click="commands.italic"
|
||||||
|
>
|
||||||
|
<icon name="italic" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.strike() }"
|
||||||
|
@click="commands.strike"
|
||||||
|
>
|
||||||
|
<icon name="strike" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.underline() }"
|
||||||
|
@click="commands.underline"
|
||||||
|
>
|
||||||
|
<icon name="underline" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.code() }"
|
||||||
|
@click="commands.code"
|
||||||
|
>
|
||||||
|
<icon name="code" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.paragraph() }"
|
||||||
|
@click="commands.paragraph"
|
||||||
|
>
|
||||||
|
<icon name="paragraph" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.heading({ level: 1 }) }"
|
||||||
|
@click="commands.heading({ level: 1 })"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.heading({ level: 2 }) }"
|
||||||
|
@click="commands.heading({ level: 2 })"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.heading({ level: 3 }) }"
|
||||||
|
@click="commands.heading({ level: 3 })"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.bullet_list() }"
|
||||||
|
@click="commands.bullet_list"
|
||||||
|
>
|
||||||
|
<icon name="ul" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.ordered_list() }"
|
||||||
|
@click="commands.ordered_list"
|
||||||
|
>
|
||||||
|
<icon name="ol" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.blockquote() }"
|
||||||
|
@click="commands.blockquote"
|
||||||
|
>
|
||||||
|
<icon name="quote" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
:class="{ 'is-active': isActive.code_block() }"
|
||||||
|
@click="commands.code_block"
|
||||||
|
>
|
||||||
|
<icon name="code" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
@click="commands.horizontal_rule"
|
||||||
|
>
|
||||||
|
<icon name="hr" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
@click="commands.undo"
|
||||||
|
>
|
||||||
|
<icon name="undo" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="menubar__button"
|
||||||
|
@click="commands.redo"
|
||||||
|
>
|
||||||
|
<icon name="redo" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</editor-menu-bar>
|
||||||
|
|
||||||
|
<editor-content class="editor__content" :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
<h1 class="title">{{thread.name}} <b-tag v-if="thread.locked">Locked</b-tag><b-button @click="replyToThread()"><i class="fas fa-reply-all"></i></b-button></h1>
|
||||||
|
<div class="">
|
||||||
|
<div class="column" v-for='(post) in thread.Posts' :key='"threadPost-" + post.id'>
|
||||||
<article class="media box">
|
<article class="media box">
|
||||||
<figure class="media-left">
|
<figure class="media-left">
|
||||||
<p class="image is-64x64">
|
<p class="image is-64x64">
|
||||||
|
@ -18,6 +154,9 @@
|
||||||
<div v-html="post.content"></div>
|
<div v-html="post.content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="media-right">
|
||||||
|
<b-button @click="replyToPost(post)"><i class="fas fa-reply"></i></b-button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -26,16 +165,72 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import AjaxErrorHandler from "../../assets/js/errorHandler";
|
import AjaxErrorHandler from "../../assets/js/errorHandler";
|
||||||
|
import { Editor, EditorContent, EditorMenuBar } from 'tiptap'
|
||||||
|
import {
|
||||||
|
Blockquote,
|
||||||
|
CodeBlock,
|
||||||
|
HardBreak,
|
||||||
|
Heading,
|
||||||
|
HorizontalRule,
|
||||||
|
OrderedList,
|
||||||
|
BulletList,
|
||||||
|
ListItem,
|
||||||
|
TodoItem,
|
||||||
|
TodoList,
|
||||||
|
Bold,
|
||||||
|
Code,
|
||||||
|
Italic,
|
||||||
|
Link,
|
||||||
|
Strike,
|
||||||
|
Underline,
|
||||||
|
History,
|
||||||
|
} from 'tiptap-extensions'
|
||||||
|
import Icon from '../components/EditorIcons'
|
||||||
export default {
|
export default {
|
||||||
name: "ForumThread",
|
name: "ForumThread",
|
||||||
|
components: {
|
||||||
|
EditorContent,
|
||||||
|
EditorMenuBar,
|
||||||
|
Icon
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
thread: [],
|
thread: [],
|
||||||
|
responseContent: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
loading: true
|
loading: true,
|
||||||
|
editor: new Editor({
|
||||||
|
extensions: [
|
||||||
|
new Blockquote(),
|
||||||
|
new BulletList(),
|
||||||
|
new CodeBlock(),
|
||||||
|
new HardBreak(),
|
||||||
|
new Heading({ levels: [1, 2, 3] }),
|
||||||
|
new HorizontalRule(),
|
||||||
|
new ListItem(),
|
||||||
|
new OrderedList(),
|
||||||
|
new TodoItem(),
|
||||||
|
new TodoList(),
|
||||||
|
new Link(),
|
||||||
|
new Bold(),
|
||||||
|
new Code(),
|
||||||
|
new Italic(),
|
||||||
|
new Strike(),
|
||||||
|
new Underline(),
|
||||||
|
new History(),
|
||||||
|
],
|
||||||
|
content: '<p>What do you have to say</p>'
|
||||||
|
}),
|
||||||
|
postModal: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
replyToThread() {
|
||||||
|
|
||||||
|
},
|
||||||
|
replyToPost(post) {
|
||||||
|
post
|
||||||
|
},
|
||||||
getThread(initial) {
|
getThread(initial) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
if(initial) {
|
if(initial) {
|
||||||
|
@ -58,6 +253,9 @@ export default {
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
this.getThread(true)
|
this.getThread(true)
|
||||||
}
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.editor.destroy()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -73,7 +73,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class='thread_display__content'>
|
<div class='thread_display__content'>
|
||||||
{{thread.Posts[0].content}}
|
{{thread.Posts[0].plainText}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="section">
|
<main>
|
||||||
<div class="columns is-centered" v-if="$store.state.user.username">
|
<div class="columns is-centered section" v-if="$store.state.user.username">
|
||||||
<div class="column is-4 has-text-centered">
|
<div class="column is-4 has-text-centered">
|
||||||
<h1 class="title has-text-centered">{{$store.state.user.username}}</h1>
|
<h1 class="title has-text-centered">{{$store.state.user.username}}</h1>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
@ -53,7 +53,89 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!$store.state.user.username">
|
<div v-if="!$store.state.user.username">
|
||||||
This route requires authentication
|
<section class="hero is-info is-large is-fullheight-with-navbar" style="overflow: auto">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<h1 class="title">
|
||||||
|
Kaverti
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
Kaverti is a new 3D sandbox gaming platform, and avatar social website.
|
||||||
|
<br />
|
||||||
|
Kaverti is home to hundreds of users who enjoy using it
|
||||||
|
<br />
|
||||||
|
So why not sign up today!
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="hero is-medium" style="overflow: auto">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<h1 class="title">
|
||||||
|
The stats:
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-vcentered has-text-centered">
|
||||||
|
<h1 class="title">{{$t('stats.users')}}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title" v-if="!loading">
|
||||||
|
{{users}}
|
||||||
|
</h1>
|
||||||
|
<b-skeleton size="is-large" :active="loading" :count="2"></b-skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-vcentered has-text-centered">
|
||||||
|
<h1 class="title">{{$t('stats.posts')}}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title" v-if="!loading">
|
||||||
|
{{posts}}
|
||||||
|
</h1>
|
||||||
|
<b-skeleton size="is-large" :active="loading" :count="2"></b-skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-vcentered has-text-centered">
|
||||||
|
<h1 class="title">{{$t('stats.purchased')}}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title" v-if="!loading">
|
||||||
|
{{inventory}}
|
||||||
|
</h1>
|
||||||
|
<b-skeleton size="is-large" :active="loading" :count="2"></b-skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-vcentered has-text-centered">
|
||||||
|
<h1 class="title">{{$t('stats.teams')}}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title" v-if="!loading">
|
||||||
|
{{teams}}
|
||||||
|
</h1>
|
||||||
|
<b-skeleton size="is-large" :active="loading" :count="2"></b-skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-vcentered has-text-centered">
|
||||||
|
<h1 class="title">{{$t('stats.items')}}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title" v-if="!loading">
|
||||||
|
{{items}}
|
||||||
|
</h1>
|
||||||
|
<b-skeleton size="is-large" :active="loading" :count="2"></b-skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-vcentered has-text-centered">
|
||||||
|
<h1 class="title">{{$t('stats.threads')}}</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title" v-if="!loading">
|
||||||
|
{{threads}}
|
||||||
|
</h1>
|
||||||
|
<b-skeleton size="is-large" :active="loading" :count="2"></b-skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
@ -75,7 +157,14 @@ export default {
|
||||||
wallText: '',
|
wallText: '',
|
||||||
mentions: '',
|
mentions: '',
|
||||||
loadingWallButton: false,
|
loadingWallButton: false,
|
||||||
loadingWall: true
|
loadingWall: true,
|
||||||
|
users: 0,
|
||||||
|
posts: 0,
|
||||||
|
inventory: 0,
|
||||||
|
teams: 0,
|
||||||
|
threads: 0,
|
||||||
|
items: 0,
|
||||||
|
loading: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -112,6 +201,18 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.axios.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'kaverti/stats')
|
||||||
|
.then(res => {
|
||||||
|
this.users = res.data.users
|
||||||
|
this.posts = res.data.posts
|
||||||
|
this.inventory = res.data.inventory
|
||||||
|
this.items = res.data.items
|
||||||
|
this.teams = res.data.teams
|
||||||
|
this.threads = res.data.threads
|
||||||
|
this.loading = false
|
||||||
|
}).catch(() => {
|
||||||
|
this.$buefy.snackbar.open({message:this.$t('errors.authFail'), type: 'is-warning'})
|
||||||
|
})
|
||||||
this.axios
|
this.axios
|
||||||
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `blog/posts`)
|
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + `/` + `blog/posts`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main class="section">
|
||||||
<div class="column is-vcentered">
|
<div class="column is-vcentered">
|
||||||
<h1 class="title">{{$t('stats.title')}}</h1>
|
<h1 class="title">{{$t('stats.title')}}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue