feat(frontend): minor cleanup & some tracking improvements

This commit is contained in:
Dimitrie Stefanescu
2022-01-31 15:40:35 +00:00
parent 7c2d7334e9
commit 43f6fcdfb5
36 changed files with 38397 additions and 23395 deletions
+38363 -17127
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -137,9 +137,10 @@ export default {
mounted() {
this.setNavResizeEvents()
let distinct_id = localStorage.getItem('distinct_id')
if (distinct_id !== null) {
this.$mixpanel.identify(distinct_id)
let mixpanelId = this.$mixpanelId()
if (mixpanelId !== null) {
this.$mixpanel.identify(mixpanelId)
this.$mixpanel.people.set('Theme Web', this.$vuetify.theme.dark ? 'dark' : 'light')
}
this.$mixpanel.track('Visit Web App')
@@ -49,6 +49,7 @@ export default {
if (!dialog.result) return
this.$mixpanel.track('User Action', { type: 'action', name: 'delete', hostApp: 'web' })
this.$mixpanel.people.set('Account deleted', true)
this.$matomo && this.$matomo.trackPageView('user/delete')
this.isLoading = true
@@ -3,7 +3,11 @@
no-gutters
:class="`my-1 py-1 property-row rounded-lg ${$vuetify.theme.dark ? 'black-bg' : 'white-bg'} ${
prop.type === 'object' || prop.type === 'array' ? (expanded ? 'border-blue' : 'border') : ''
} ${prop.type === 'object' || prop.type === 'array' ? 'hover-cursor property-row-hover' : 'normal-cursor'}`"
} ${
prop.type === 'object' || prop.type === 'array'
? 'hover-cursor property-row-hover'
: 'normal-cursor'
}`"
@click.stop="prop.type === 'object' || prop.type === 'array' ? (expanded = !expanded) : null"
>
<v-col cols="1" class="text-center">
@@ -201,7 +201,11 @@ export default {
}
},
mounted() {
this.$mixpanel.track('Share Stream', { type: 'action', hostApp: 'web' })
this.$mixpanel.track('Share Stream', {
type: 'action',
location: this.$route.name,
hostApp: 'web'
})
},
methods: {
copyToClipboard(e) {
@@ -442,7 +442,12 @@ export default {
data: resId.length === 10 ? await this.loadCommit(resId) : await this.loadObject(resId)
}
this.resources.push(resource)
this.$mixpanel.track('Viewer Action', {
type: 'action',
name: 'add',
resourceType: resource.type,
hostApp: 'web'
})
// TODO add to url
let fullQuery = { ...this.$route.query }
delete fullQuery.overlay
@@ -479,6 +484,13 @@ export default {
: resource.data.object.id
}`
this.$mixpanel.track('Viewer Action', {
type: 'action',
name: 'remove',
resourceType: resource.type,
hostApp: 'web'
})
await window.__viewer.unloadObject(url)
window.__viewer.zoomExtents(undefined, true)
}
@@ -89,9 +89,9 @@ import gql from 'graphql-tag'
export default {
name: 'Details',
components: {
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder'),
NoDataPlaceholder: () => import('@/cleanup/components/common/NoDataPlaceholder'),
ListItemCommit: () => import('@/cleanup/components/stream/ListItemCommit'),
PreviewImage: () => import('@/components/PreviewImage'),
PreviewImage: () => import('@/cleanup/components/common/PreviewImage'),
StreamActivity: () => import('@/views/stream/Activity')
},
data() {
@@ -1,69 +0,0 @@
<template>
<v-card rounded="lg" :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`">
<v-toolbar flat>
<v-toolbar-title>Applications</v-toolbar-title>
</v-toolbar>
<v-card-text>
Register and manage third-party Speckle Apps that, once authorised by a user on this server,
can act on their behalf.
</v-card-text>
<v-card-text v-if="$apollo.loading">Loading...</v-card-text>
<v-card-text v-if="apps && apps.length !== 0">
<v-list two-line class="transparent">
<list-item-user-app
v-for="app in apps"
:key="app.id"
:app="app"
@app-edited="refreshList"
@deleted="refreshList"
/>
</v-list>
</v-card-text>
<v-card-text v-else>You have no apps.</v-card-text>
<v-card-text>
<v-btn class="mb-5" @click="appDialog = true">new app</v-btn>
<v-dialog v-model="appDialog" width="500">
<app-new-dialog @app-added="refreshList" @close="appDialog = false" />
</v-dialog>
</v-card-text>
</v-card>
</template>
<script>
import gql from 'graphql-tag'
import ListItemUserApp from './ListItemUserApp'
import AppNewDialog from './dialogs/AppNewDialog'
export default {
components: { ListItemUserApp, AppNewDialog },
data() {
return {
appDialog: false
}
},
apollo: {
apps: {
query: gql`
query {
user {
id
createdApps {
id
secret
name
description
redirectUrl
}
}
}
`,
update: (data) => data.user.createdApps
}
},
methods: {
refreshList() {
this.$apollo.queries.apps.refetch()
}
}
}
</script>
@@ -1,195 +0,0 @@
<template>
<v-card :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''} mt-10`">
<v-toolbar flat>
<v-toolbar-title>Usage Stats</v-toolbar-title>
</v-toolbar>
<v-row v-if="!$apollo.loading" dense class="mt-2">
<v-col v-for="value in graphSeries" v-if="value.data" :key="value.name" cols="12" sm="6">
<p class="text-center caption primary--text">
<v-icon x-small color="primary" class="mr-1">{{ icons[value.name] }}</v-icon>
{{ capitalize(value.name.split('History')[0]) }} history
</p>
<apexchart class="primary--text" type="bar" :options="options" :series="[value]" />
</v-col>
</v-row>
</v-card>
</template>
<script>
import gql from 'graphql-tag'
import { formatNumber } from '@/formatNumber.js'
export default {
name: 'ActivityCard',
components: {},
data() {
return {
icons: {
commitHistory: 'mdi-cloud-upload-outline',
streamHistory: 'mdi-cloud-outline',
objectHistory: 'mdi-cube-outline',
userHistory: 'mdi-account-outline'
},
options: {
states: {
active: {
filter: {
type: 'none' /* none, lighten, darken */
}
}
},
chart: {
id: 'newUserData',
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
dataLabels: {
enabled: true,
position: 'bottom',
formatter: function (val) {
return formatNumber(val)
},
offsetY: -25,
style: {
fontSize: '10px',
fontFamily: 'Helvetica, Arial, sans-serif',
fontWeight: 'bold',
colors: undefined
},
background: {
enabled: true,
foreColor: '#fff',
padding: 6,
borderRadius: 5,
borderWidth: 2,
borderColor: undefined,
opacity: 0.9
}
},
tooltip: {
enabled: false
},
xaxis: {
type: 'datetime',
axisBorder: {
show: false
},
labels: {
show: true,
rotate: 0,
rotateAlways: true,
hideOverlappingLabels: true,
showDuplicates: false,
trim: false,
style: {
colors: [],
fontSize: '12px',
fontFamily: 'Helvetica, Arial, sans-serif',
fontWeight: 400,
cssClass: 'apexcharts-xaxis-label text-center'
},
offsetX: 0,
offsetY: 0
}
},
yaxis: {
show: false,
axisTicks: {
show: false
}
},
grid: {
show: false
},
plotOptions: {
bar: {
borderRadius: 10,
columnWidth: '90%',
barHeight: '10%',
dataLabels: {
position: 'top' // top, center, bottom
}
}
}
}
}
},
apollo: {
serverStats: {
query: gql`
query {
serverStats {
commitHistory
objectHistory
userHistory
streamHistory
}
}
`,
update(data) {
var stats = data.serverStats
delete stats.__typename
return stats
}
}
},
computed: {
graphSeries() {
let result = []
let months = this.past12Months()
if (this.serverStats) {
result = Object.keys(this.serverStats).map((key) => {
let category = this.serverStats[key]
let processed = []
months?.forEach((month) => {
let totalCount = 0
category.forEach((value) => {
let date = this.parseISOString(value.created_month)
if (this.isSameMonth(month, date)) {
totalCount = value.count
}
})
processed.push([month, totalCount])
})
return { name: key, data: processed }
})
}
return result
}
},
methods: {
capitalize(word) {
return word[0].toUpperCase() + word.slice(1).toLowerCase()
},
past12Months() {
let now = new Date(Date.now())
let dates = []
for (let i = 0; i < 12; i++) {
let d = new Date(now.getFullYear(), now.getMonth() - i, 2)
dates.push(d)
}
return dates
},
isSameMonth(refDate, date) {
return (
refDate.getUTCFullYear() === date.getUTCFullYear() &&
refDate.getUTCMonth() === date.getUTCMonth()
)
},
parseISOString(s) {
let b = s.split(/\D+/)
return new Date(Date.UTC(b[0], --b[1], b[2], b[3], b[4], b[5], b[6]))
},
isoFormatDMY(d) {
function pad(n) {
return (n < 10 ? '0' : '') + n
}
return pad(d.getUTCDate()) + '/' + pad(d.getUTCMonth() + 1) + '/' + d.getUTCFullYear()
}
}
}
</script>
@@ -1,66 +0,0 @@
<template>
<section-card expandable>
<template #header>General Info</template>
<v-row class="d-flex justify-space-around mt-4">
<v-col
v-for="(value, name) in serverStats"
:key="name"
cols="6"
sm="6"
md="3"
class="flex-grow-1"
>
<h4 class="primary--text text--lighten-2 text-center">Total {{ name }}</h4>
<v-tooltip bottom color="primary" :disabled="value < 1000">
<template #activator="{ on, attrs }">
<p
class="primary--text text-h3 text-md-h2 text-lg-h1 text-center"
v-bind="attrs"
v-on="on"
>
<animated-number :value="value" class="speckle-gradient-txt" />
</p>
</template>
<span>{{ value }}</span>
</v-tooltip>
</v-col>
</v-row>
</section-card>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'GeneralInfoCard',
components: {
AnimatedNumber: () => import('@/components/AnimatedNumber'),
SectionCard: () => import('@/cleanup/components/common/SectionCard')
},
apollo: {
serverStats: {
query: gql`
query {
serverStats {
totalObjectCount
totalCommitCount
totalStreamCount
totalUserCount
}
}
`,
update(data) {
var stats = data.serverStats
return {
users: stats.totalUserCount,
streams: stats.totalStreamCount,
commits: stats.totalCommitCount,
objects: stats.totalObjectCount
}
}
}
}
}
</script>
<style scoped lang="scss"></style>
@@ -1,117 +0,0 @@
<template>
<v-card>
<v-toolbar>
<v-toolbar-title>Server Admin</v-toolbar-title>
</v-toolbar>
<template slot="menu">
<v-slide-x-reverse-transition mode="out-in">
<v-tooltip v-if="!addUserMode" left color="primary" open-delay="500">
<template v-slot:activator="{ on, attrs }">
<v-btn
rounded
small
outlined
color="primary"
v-bind="attrs"
v-on="on"
class="mr-1"
@click="addUserMode = true"
>
<v-icon small>mdi-plus</v-icon>
Add
</v-btn>
</template>
Add new admin
</v-tooltip>
<div v-else class="d-flex align-center">
<v-autocomplete
:search-input.sync="search"
v-model="selectedSearchItem"
:items="filteredSearchResults"
:loading="$apollo.loading"
autofocus
label="Search users..."
dense
hide-details
hide-no-data
item-text="name"
item-value="id"
return-object
chips
deletable-chips
class="mr-2"
>
<template #item="{ item }">
<v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle>{{ item.email }}</v-list-item-subtitle>
</v-list-item-content>
</template>
</v-autocomplete>
<v-btn outlined small color="success" elevation="0" class="mr-2">Add</v-btn>
<v-btn outlined small color="error" elevation="0" @click="addUserMode = false">
Cancel
</v-btn>
</div>
</v-slide-x-reverse-transition>
</template>
<div v-for="admin in adminUsers" :key="admin.id">
<server-admins-user :admin="admin" />
</div>
</v-card>
</template>
<script>
import ServerAdminsUser from '@/components/admin/ServerAdminsUser'
import SearchBar from '@/components/SearchBar'
import userSearchQuery from '@/graphql/userSearch.gql'
export default {
name: 'ServerAdminsCard',
components: { SearchBar, ServerAdminsUser },
props: ['adminUsers'],
data() {
return {
selectedSearchItem: [],
search: '',
userSearch: { items: [] },
addUserMode: false
}
},
apollo: {
userSearch: {
query: userSearchQuery,
variables() {
return {
query: this.search,
limit: 25
}
},
skip() {
return !this.search || this.search.length < 3
},
debounce: 300
}
},
computed: {
filteredSearchResults() {
if (!this.userSearch) return null
let users = []
for (let u of this.userSearch.items) {
if (u.id === this.myId) continue
users.push(u)
}
return users
},
myId() {
return localStorage.getItem('uuid')
}
},
methods: {
addUser(event, user) {
console.log('user to add as admin', event, user)
this.addUserMode = !this.addUserMode
}
}
}
</script>
@@ -1,79 +0,0 @@
<template>
<user-list-item :admin="admin" :widgets="widgets">
<v-slide-x-reverse-transition hide-on-leave>
<v-menu offset-y left rounded v-if="!deleteRequested">
<template v-slot:activator="{attrs,on}">
<v-btn icon small v-bind="attrs" v-on="on" class="ml-2">
<v-icon small>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list nav dense>
<v-tooltip left max-width="200pt" open-delay="500">
<template v-slot:activator="{attrs, on}">
<v-list-item v-bind="attrs" v-on="on" v-for="opt in menuOptions" :key="opt.text" link @click="deleteRequested = true">
<v-list-item-icon class="mr-3">
<v-icon color="error" small v-text="opt.icon"></v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="opt.text" class="error--text"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
Removes this user's admin privileges. This <b>will not</b> delete the user's account.
</v-tooltip>
</v-list>
</v-menu>
<div v-else>
<p class="caption mb-1 text-center">Are you sure?</p>
<v-btn small color="success" @click="removeUserAdmin(admin)" class="mr-1">Yes</v-btn>
<v-btn small color="error" @click="deleteRequested = !deleteRequested">Cancel</v-btn>
</div>
</v-slide-x-reverse-transition>
</user-list-item>
</template>
<script>
import AnimatedNumber from "@/components/AnimatedNumber";
import UserListItem from "@/components/admin/UserListItem";
export default {
name: "server-admins-user",
components: { UserListItem, AnimatedNumber },
props: {
admin: {}
},
data() {
return {
deleteRequested: false,
menuOptions: [
{
text: "Remove",
icon: "mdi-delete"
}
]
};
},
computed: {
widgets(){
return [
{
icon: 'mdi-eye',
hint: 'Last seen',
value: '< 1 day',
type: 'text'
}
]
}
},
methods: {
removeUserAdmin(admin) {
console.log("Requested removal of user from admin scope", admin);
this.deleteRequested = false;
}
}
};
</script>
<style scoped lang="scss">
.admin-user-view {
border-bottom: 1pt dotted var(--v-background-darken1);
}
</style>
@@ -1,87 +0,0 @@
<template>
<div class="d-flex align-center pa-3 admin-user-view">
<div class="d-flex flex-grow-1 align-center">
<img
height="60pt"
width="60pt"
class="rounded-circle overflow-hidden elevation-1"
contain
:src="admin.avatar"
/>
<div class="d-flex flex-column flex-grow-1 ml-2" style="min-width: 30%">
<span class="subtitle-1">{{ admin.name }}</span>
<span class="caption">
<v-icon x-small>mdi-email-outline</v-icon>
{{ admin.email }}
<v-tooltip right :color="admin.verified ? 'success' : 'error'" class="caption">
<template v-slot:activator="{attrs, on}">
<v-icon v-bind="attrs" v-on="on" small :color="admin.verified ? 'success' : 'error'">{{admin.verified ? 'mdi-check-decagram' : 'mdi-alert-decagram'}}</v-icon>
</template>
<span class="caption">{{admin.verified ? 'Verified' : 'Pending verification'}}</span>
</v-tooltip>
</span>
<span class="caption">
<v-icon x-small>mdi-domain</v-icon>
{{ admin.company }}
</span>
</div>
<slot name="mid"></slot>
<div v-if="widgets" class="d-flex widget-parent">
<div v-for="(col, index) in organizedWidgets" :key="index" class="d-flex flex-column align-content-start justify-center pr-2">
<v-tooltip v-for="widget in col" left color="primary" :key="widget.text">
<template v-slot:activator="{attrs, on}">
<span v-bind="attrs"
v-on="on"
class="caption" :class="(widget.color || 'dark') + '--text'">
<v-icon small class="pr-1" :class="(widget.color || 'dark') + '--text'">{{widget.icon}}</v-icon>
<animated-number v-if="widget.type === 'number'" :value="widget.value"/>
<span v-else>{{ widget.value }}</span>
</span>
</template>
<span class="caption">
{{ widget.hint }}
</span>
</v-tooltip>
</div>
</div>
<slot></slot>
</div>
</div>
</template>
<script>
import AnimatedNumber from "@/components/AnimatedNumber";
export default {
name: "user-list-item",
components: { AnimatedNumber },
props: {
admin: {},
widgets: {},
},
data() {
return {
}
},
computed: {
organizedWidgets(){
var cols = []
for (let i = 0; i < this.widgets.length; i+=3) {
var row =[]
for (let j = 0; j < 3; j++) {
var index = i + j;
if(index >= this.widgets.length) break;
row.push(this.widgets[index])
}
cols.push(row)
}
return cols
}
}
};
</script>
<style scoped lang="scss">
.admin-user-view {
border-bottom: 1pt dotted var(--v-background-darken1);
}
</style>
@@ -1,84 +0,0 @@
<template>
<v-card :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''} mt-10`">
<v-toolbar flat>
<v-toolbar-title>Version Info</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon href="https://github.com/specklesystems/speckle-server/releases" target="_blank">
<span v-if="isLatestVersion" class="text--h6 success--text">
<v-icon v-tooltip="'Up to date.'" size="medium" color="success">mdi-check-bold</v-icon>
</span>
<span v-else class="warning--text">
<v-icon v-tooltip="'There is a newer version available!'" size="medium" color="warning">
mdi-alert
</v-icon>
</span>
</v-btn>
</v-toolbar>
<div class="d-flex justify-space-around pl-4 pr-4 mt-4">
<div>
<h4 class="primary--text text--lighten-2">Current</h4>
<p class="primary--text text-h4 text-sm-h2 speckle-gradient-txt">
{{ versionInfo.current }}
</p>
</div>
<v-icon color="primary lighten-1">mdi-arrow-right</v-icon>
<div>
<h4 class="primary--text text--lighten-2">Latest</h4>
<p class="primary--text text-h4 text-sm-h2 speckle-gradient-txt">
{{ versionInfo.latest }}
</p>
</div>
</div>
</v-card>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'VersionInfoCard',
components: {},
data() {
return {
versionInfo: {
current: '2.0.18',
latest: '2.0.27'
}
}
},
apollo: {
currentVersion: {
query: gql`
query {
serverInfo {
version
}
}
`,
update(data) {
this.versionInfo.current = data.serverInfo.version
}
}
},
computed: {
isLatestVersion() {
return this.versionInfo.current === this.versionInfo.latest
}
},
async mounted() {
this.versionInfo.latest = await this.getLatestVersion()
},
methods: {
getLatestVersion() {
return fetch('https://api.github.com/repos/specklesystems/speckle-server/releases/latest')
.then(async (res) => {
var x = await res.json()
return x.tag_name
})
.catch((err) => console.error('error fetch', err))
}
}
}
</script>
<style scoped></style>
+4
View File
@@ -4,6 +4,10 @@ Vue.prototype.$userId = function () {
return localStorage.getItem('uuid')
}
Vue.prototype.$mixpanelId = function () {
return localStorage.getItem('distinct_id')
}
Vue.prototype.$loggedIn = function () {
return localStorage.getItem('uuid') !== null
}
-390
View File
@@ -1,390 +0,0 @@
<template>
<v-app id="speckle">
<v-navigation-drawer
:app="!$vuetify.breakpoint.xsOnly"
permanent
mini-variant
:expand-on-hover="true || $vuetify.breakpoint.mdAndUp"
floating
stateless
fixed
:color="`${$vuetify.theme.dark ? 'grey darken-4' : 'grey lighten-4'}`"
:dark="$vuetify.theme.dark"
style="z-index: 100"
:class="`elevation-5 hidden-xs-only`"
mini-variant-width="56"
>
<v-toolbar class="transparent elevation-0">
<v-toolbar-title class="space-grotesk primary--text">
<router-link to="/" class="text-decoration-none">
<v-img
class="mt-2 hover-tada"
width="24"
src="@/assets/specklebrick.png"
style="display: inline-block"
/>
</router-link>
<router-link
to="/"
class="text-decoration-none"
style="position: relative; top: -4px; margin-left: 20px"
>
<span class="pb-4"><b>Speckle</b></span>
</router-link>
</v-toolbar-title>
</v-toolbar>
<v-list>
<v-list-item link to="/" style="height: 59px">
<v-list-item-icon>
<v-icon>mdi-clock-fast</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Feed</v-list-item-title>
<v-list-item-subtitle class="caption">Latest events.</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item link to="/streams" style="height: 59px">
<v-list-item-icon>
<v-icon>mdi-folder</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Streams</v-list-item-title>
<v-list-item-subtitle class="caption">All your streams.</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="user" link to="/profile" style="height: 59px">
<v-list-item-icon>
<user-avatar-icon :size="24" :avatar="user.avatar" :seed="user.id"></user-avatar-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Profile</v-list-item-title>
<v-list-item-subtitle class="caption">
Your profile & dev settings.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
<v-list-item v-if="serverInfo">
<v-list-item-icon>
<v-icon
v-if="isDevServer"
v-tooltip="`This is a test server and should not be used in production!`"
color="red"
>
mdi-alert
</v-icon>
<v-icon v-else>mdi-information-variant</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="caption">{{ serverInfo.name }}</v-list-item-title>
<v-list-item-subtitle class="caption">
{{ serverInfo.version }}
</v-list-item-subtitle>
<div class="caption">
{{ serverInfo.description }}
</div>
</v-list-item-content>
</v-list-item>
</v-list>
<template #append>
<v-list dense>
<v-list-item
link
href="https://speckle.community/new-topic?category=features"
target="_blank"
class="primary"
dark
>
<v-list-item-icon>
<v-icon small class="ml-1">mdi-comment-arrow-right</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Feedback</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="user" link color="primary" @click="signOut()">
<v-list-item-icon>
<v-icon small class="ml-1">mdi-account-off</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="user && user.role === 'server:admin'" link to="/admin" color="primary">
<v-list-item-icon>
<v-icon small class="ml-1">mdi-cog</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Server Admin</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item link @click="switchTheme">
<v-list-item-icon>
<v-icon small class="ml-1">mdi-theme-light-dark</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Switch Theme</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
</v-navigation-drawer>
<v-bottom-navigation fixed xxx-hide-on-scroll class="hidden-sm-and-up elevation-20">
<v-btn color="primary" text to="/" style="height: 100%">
<span>Feed</span>
<v-icon>mdi-clock-fast</v-icon>
</v-btn>
<v-btn color="primary" text to="/streams" style="height: 100%">
<span>Streams</span>
<v-icon>mdi-folder</v-icon>
</v-btn>
<v-btn color="primary" text to="/profile" style="height: 100%">
<span>Profile</span>
<v-icon>mdi-account</v-icon>
</v-btn>
<v-btn text style="height: 100%" @click="bottomSheet = true">
<span>More</span>
<v-icon>mdi-dots-horizontal</v-icon>
</v-btn>
</v-bottom-navigation>
<v-bottom-sheet v-model="bottomSheet">
<v-sheet class="">
<v-toolbar class="transparent elevation-0">
<v-toolbar-title class="space-grotesk primary--text">
<router-link to="/" class="text-decoration-none">
<v-img
class="mt-2"
max-width="30"
src="@/assets/logo.svg"
style="display: inline-block"
/>
</router-link>
<router-link
to="/"
class="text-decoration-none"
style="position: relative; top: -4px; margin-left: 20px"
>
<span class="pb-4"><b>Speckle</b></span>
</router-link>
</v-toolbar-title>
</v-toolbar>
<v-list>
<v-list-item
link
href="https://speckle.community/new-topic?category=features"
target="_blank"
class="primary"
dark
>
<v-list-item-icon>
<v-icon small class="ml-1">mdi-comment-arrow-right</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Feedback</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="user" link color="primary" @click="signOut()">
<v-list-item-icon>
<v-icon small class="ml-1">mdi-account-off</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="user && user.role === 'server:admin'" link to="/admin" color="primary">
<v-list-item-icon>
<v-icon small class="ml-1">mdi-cog</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Server Admin</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item link @click="switchTheme">
<v-list-item-icon>
<v-icon small class="ml-1">mdi-theme-light-dark</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Switch Theme</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="serverInfo">
<v-list-item-icon>
<v-icon
v-if="serverInfo && isDevServer"
v-tooltip="`This is a test server and should not be used in production!`"
color="red"
>
mdi-alert
</v-icon>
<v-icon v-else>mdi-information-variant</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="caption">{{ serverInfo.name }}</v-list-item-title>
<v-list-item-subtitle class="caption">
{{ serverInfo.version }}
</v-list-item-subtitle>
<div class="caption">This is a test server and should not be used in production!</div>
</v-list-item-content>
</v-list-item>
</v-list>
</v-sheet>
</v-bottom-sheet>
<v-main style="overflow: hidden">
<transition name="fade">
<router-view></router-view>
</transition>
</v-main>
<v-snackbar
v-model="streamSnackbar"
rounded="pill"
:timeout="10000"
style="z-index: 10000"
:color="`${$vuetify.theme.dark ? 'primary' : 'primary'}`"
>
<span v-if="streamSnackbarInfo.sharedBy">You have been granted access to a new stream!</span>
<span v-else>New stream created!</span>
<template #action="{ attrs }">
<v-btn color="white" text v-bind="attrs" @click="goToStreamAndCloseSnackbar()">View</v-btn>
<v-btn color="pink" icon v-bind="attrs" @click="streamSnackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-app>
</template>
<script>
import gql from 'graphql-tag'
import { signOut } from '@/auth-helpers'
import userQuery from '../graphql/user.gql'
import UserAvatarIcon from '@/components/UserAvatarIcon'
export default {
components: { UserAvatarIcon },
inject: ['mixpanel'],
data() {
return {
streamSnackbar: false,
streamSnackbarInfo: {},
bottomSheet: false
}
},
apollo: {
serverInfo: {
query: gql`
query {
serverInfo {
name
company
description
adminContact
version
}
}
`
},
user: {
query: userQuery,
skip() {
return !this.loggedIn
}
},
$subscribe: {
userStreamAdded: {
query: gql`
subscription {
userStreamAdded
}
`,
result(streamInfo) {
console.log(streamInfo)
if (!streamInfo.data.userStreamAdded) return
this.streamSnackbar = true
this.streamSnackbarInfo = streamInfo.data.userStreamAdded
},
skip() {
return !this.loggedIn
}
}
}
},
computed: {
background() {
let theme = this.$vuetify.theme.dark ? 'dark' : 'light'
return `background-color: ${this.$vuetify.theme.themes[theme].background};`
},
isDevServer() {
return this.serverInfo.version.match(/^[0-9]+\.[0-9]+\.[0-9]+$/) ? false : true
},
loggedIn() {
return localStorage.getItem('uuid') !== null
}
},
watch: {
$route(to) {
this.bottomSheet = false
// close the snackbar if it's a stream create event in this window
if (to.params.streamId === this.streamSnackbarInfo.id) this.streamSnackbar = false
this.bottomSheet = false
}
},
methods: {
signOut() {
signOut()
},
switchTheme() {
this.$vuetify.theme.dark = !this.$vuetify.theme.dark
localStorage.setItem('darkModeEnabled', this.$vuetify.theme.dark ? 'dark' : 'light')
this.$mixpanel.people.set('Theme Web', this.$vuetify.theme.dark ? 'dark' : 'light')
},
showStreamInviteDialog() {
this.$refs.streamInviteDialog.show()
},
goToStreamAndCloseSnackbar() {
this.streamSnackbar = false
this.$router.push(`/streams/${this.streamSnackbarInfo.id}`)
}
}
}
</script>
<style>
.space-grotesk {
font-family: 'Space Grotesk' !important;
}
.logo {
font-family: 'Space Grotesk', sans-serif;
text-transform: none;
color: rgb(37, 99, 235);
font-weight: 500;
font-size: 1rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.no-hover:before {
display: none;
}
</style>
-80
View File
@@ -1,80 +0,0 @@
<template>
<v-container
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px;' : ''} max-width: 1024px;`"
>
<!-- <v-container :fluid="$vuetify.breakpoint.mdAndDown"> -->
<v-row>
<v-col
cols="12"
:style="`margin-top: ${$vuetify.breakpoint.smAndDown ? '0px' : '50px'}`"
class="pa-3"
>
<user-info-card :user="user" @update="update"></user-info-card>
</v-col>
<v-col cols="12">
<user-authorised-apps />
<v-alert type="info" class="my-5 mt-10 mx-4">
Heads up! The sections below are intended for developers.
</v-alert>
<v-card color="transparent" class="elevation-0 mt-3">
<v-card-title>
Trying to learn the api?
<v-spacer />
<v-btn href="/explorer" text color="primary" target="_blank">
Checkout the GraphIQL explorer!
</v-btn>
</v-card-title>
</v-card>
<v-card color="transparent" flat>
<v-card-text>
<user-access-tokens />
</v-card-text>
<v-card-text>
<user-apps />
<user-delete-card :user="user" />
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import userQuery from '../graphql/user.gql'
import UserInfoCard from '../components/UserInfoCard'
import UserAccessTokens from '../components/UserAccessTokens'
import UserApps from '../components/UserApps'
import UserAuthorisedApps from '../components/UserAuthorisedApps'
import UserDeleteCard from '../components/UserDeleteCard'
export default {
name: 'Profile',
components: {
UserInfoCard,
UserAccessTokens,
UserApps,
UserAuthorisedApps,
UserDeleteCard
},
data: () => ({}),
apollo: {
user: {
query: userQuery
}
},
computed: {},
methods: {
update() {
this.$apollo.queries.user.refetch()
}
}
}
</script>
<style scoped>
.v-item-group {
float: left;
}
.clear {
clear: both;
}
</style>
@@ -1,95 +0,0 @@
<template>
<!-- <v-container :fluid="$vuetify.breakpoint.mdAndDown"> -->
<v-container :style="`${ !$vuetify.breakpoint.xsOnly ? 'padding-left: 56px;' : ''} max-width: 1024px;`" >
<v-row v-if="$apollo.loading">
<v-col cols="12">
<v-skeleton-loader type="card, article"></v-skeleton-loader>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<user-info-card :user="user"></user-info-card>
</v-col>
<v-col cols="12" class="pt-10">
<v-card class="mb-3 elevation-0">
<v-card-title>
{{ user.name }} and you share {{ user.streams.totalCount }}
{{ user.streams.totalCount === 1 ? 'stream' : 'streams' }}. {{ user.name.split(' ')[0] }} has
{{ user.commits.totalCount }}
{{ user.commits.totalCount === 1 ? 'commit' : 'commits' }}.
</v-card-title>
</v-card>
<v-row>
<v-col v-for="(stream, i) in user.streams.items" :key="i" cols="12" sm="6" lg="4">
<list-item-stream :stream="stream"></list-item-stream>
</v-col>
</v-row>
</v-col>
</v-row>
</v-container>
</template>
<script>
import UserInfoCard from '../components/UserInfoCard'
import ListItemStream from '../components/ListItemStream'
import gql from 'graphql-tag'
export default {
name: 'ProfileUser',
components: { UserInfoCard, ListItemStream },
data: () => ({}),
apollo: {
user: {
query: gql`
query User($id: String!) {
user(id: $id) {
id
email
name
bio
company
avatar
verified
profiles
role
suuid
streams {
totalCount
items {
id
description
updatedAt
name
}
}
commits {
totalCount
}
}
}
`,
variables() {
return {
id: this.$route.params.userId
}
}
}
},
computed: {},
created() {
// Move to self profile
if (this.$route.params.userId === localStorage.getItem('uuid')) {
this.$router.replace({ path: '/profile' })
}
},
methods: {}
}
</script>
<style scoped>
.v-item-group {
float: left;
}
.clear {
clear: both;
}
</style>
-302
View File
@@ -1,302 +0,0 @@
<template>
<v-container
:class="`${$vuetify.breakpoint.xsOnly ? 'pl-2' : ''}`"
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`"
fluid
pt-4
pr-0
>
<v-navigation-drawer
v-model="streamNav"
app
fixed
:permanent="streamNav && !$vuetify.breakpoint.smAndDown"
:style="`${!$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
>
<main-nav-actions :open-new-stream="newStreamDialog" />
<div v-if="user">
<v-list dense class="py-0">
<v-subheader class="caption ml-2">Your stats:</v-subheader>
<v-list-item>
<v-list-item-icon>
<v-icon small class="">mdi-folder-multiple</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle class="caption">
<b>{{ user.streams.totalCount }}</b>
total streams
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-icon>
<v-icon small class="">mdi-source-commit</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle class="caption">
<b>{{ user.commits.totalCount }}</b>
total commits
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-list
v-if="userCommits && userCommits.commits.items.length !== 0"
color="transparent"
dense
class="py-0"
>
<v-subheader class="ml-2">Your latest commits:</v-subheader>
<v-list-item
v-for="(commit, i) in userCommits.commits.items"
v-if="commit"
:key="i"
v-tooltip="`In stream '${commit.streamName}'`"
:to="`streams/${commit.streamId}/${
commit.branchName === 'globals' ? 'globals' : 'commits'
}/${commit.id}`"
>
<v-list-item-content>
<v-list-item-title>
{{ commit.message }}
</v-list-item-title>
<v-list-item-subtitle class="caption">
<i>
Updated
<timeago :datetime="commit.createdAt"></timeago>
</i>
on
<v-icon style="font-size: 10px">mdi-source-branch</v-icon>
{{ commit.branchName }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</div>
</v-navigation-drawer>
<v-app-bar app :style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`" flat>
<v-app-bar-nav-icon @click="streamNav = !streamNav"></v-app-bar-nav-icon>
<v-toolbar-title class="space-grotesk pl-0">
<v-icon class="mb-1 hidden-xs-only">mdi-folder</v-icon>
Streams
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items v-if="$vuetify.breakpoint.smAndDown" style="margin-right: -18px">
<v-btn color="primary" depressed @click="newStreamDialog++">
<v-icon>mdi-plus-box</v-icon>
</v-btn>
</v-toolbar-items>
</v-app-bar>
<!-- <getting-started-wizard /> -->
<v-row class="pl-2 pr-4" no-gutters>
<v-col v-if="$apollo.loading">
<v-row>
<v-col v-for="i in 6" :key="i" cols="12" sm="6" md="6" lg="4" xl="4">
<v-skeleton-loader type="card, list-item-two-line" class="ma-2"></v-skeleton-loader>
</v-col>
</v-row>
<div v-if="$apollo.loading" class="my-5"></div>
</v-col>
<v-col v-else-if="streams && streams.items && streams.items.length > 0" cols="12">
<v-row :class="`${$vuetify.breakpoint.xsOnly ? '' : 'pl-2'}`">
<v-col
v-for="(stream, i) in streams.items"
:key="i"
cols="12"
sm="6"
md="6"
lg="4"
xl="4"
>
<list-item-stream :stream="stream"></list-item-stream>
</v-col>
<infinite-loading
v-if="streams.items.length < streams.totalCount"
@infinite="infiniteHandler"
>
<div slot="no-more">These are all your streams!</div>
<div slot="no-results">There are no streams to load</div>
</infinite-loading>
</v-row>
</v-col>
<v-col v-else cols="12">
<no-data-placeholder v-if="user">
<h2>Welcome {{ user.name.split(' ')[0] }}!</h2>
<p class="caption">
Once you will create a stream and start sending some data, your activity will show up
here.
</p>
<template #actions>
<v-list rounded class="transparent">
<v-list-item link class="primary mb-4" dark @click="newStreamDialog++">
<v-list-item-icon>
<v-icon>mdi-plus-box</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Create a new stream!</v-list-item-title>
<v-list-item-subtitle class="caption">
Streams are like folders, or data repositories.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
</no-data-placeholder>
</v-col>
</v-row>
</v-container>
</template>
<script>
import gql from 'graphql-tag'
import streamsQuery from '../graphql/streams.gql'
import userQuery from '../graphql/user.gql'
export default {
name: 'Streams',
components: {
InfiniteLoading: () => import('vue-infinite-loading'),
ListItemStream: () => import('@/components/ListItemStream'),
MainNavActions: () => import('@/components/MainNavActions'),
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder')
},
apollo: {
streams: {
prefetch: true,
query: streamsQuery,
fetchPolicy: 'cache-and-network' //https://www.apollographql.com/docs/react/data/queries/
},
user: {
query: userQuery,
skip() {
return !this.loggedIn
}
},
userCommits: {
query: gql`
query {
userCommits: user {
id
commits(limit: 7) {
totalCount
items {
id
message
sourceApplication
streamId
streamName
branchName
createdAt
}
}
}
}
`,
skip() {
return !this.loggedIn
}
},
$subscribe: {
userStreamAdded: {
query: gql`
subscription {
userStreamAdded
}
`,
result() {
this.$apollo.queries.streams.refetch()
},
skip() {
return !this.loggedIn
}
},
userStreamRemoved: {
query: gql`
subscription {
userStreamRemoved
}
`,
result() {
this.$apollo.queries.streams.refetch()
},
skip() {
return !this.loggedIn
}
}
}
},
data: () => ({
streams: [],
streamNav: true,
newStreamDialog: 0
}),
computed: {
loggedIn() {
return localStorage.getItem('uuid') !== null
}
},
watch: {
streams(val) {
if (val.items.length === 0 && !localStorage.getItem('onboarding')) {
this.$router.push('/onboarding')
}
}
},
mounted() {
setTimeout(
function () {
this.streamNav = !this.$vuetify.breakpoint.smAndDown
}.bind(this),
100
)
},
methods: {
showServerInviteDialog() {
this.$refs.serverInviteDialog.show()
},
infiniteHandler($state) {
this.$apollo.queries.streams.fetchMore({
variables: {
cursor: this.streams.cursor
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newItems = fetchMoreResult.streams.items
//set vue-infinite state
if (newItems.length === 0) $state.complete()
else $state.loaded()
return {
streams: {
__typename: previousResult.streams.__typename,
totalCount: fetchMoreResult.streams.totalCount,
cursor: fetchMoreResult.streams.cursor,
// Merging the new streams
items: [...previousResult.streams.items, ...newItems]
}
}
}
})
}
}
}
</script>
<style scoped>
.recent-commits a {
color: inherit;
text-decoration: none;
font-weight: 500;
}
.recent-commits a:hover {
text-decoration: underline;
}
</style>
-251
View File
@@ -1,251 +0,0 @@
<template>
<v-container fluid>
<portal to="title">
<div class="font-weight-bold">Feed</div>
</portal>
<v-row class="pr-4">
<v-col v-if="$apollo.loading && !timeline">
<div class="my-5">
<v-timeline align-top dense>
<v-timeline-item v-for="i in 6" :key="i" medium>
<v-skeleton-loader type="article"></v-skeleton-loader>
</v-timeline-item>
</v-timeline>
</div>
</v-col>
<v-col
v-else-if="timeline && timeline.items.length > 0"
cols="12"
lg="8"
class="pr-2"
:style="`${$vuetify.breakpoint.xsOnly ? 'margin-left: -20px;' : ''}`"
>
<div>
<div v-if="timeline" key="activity-list">
<v-timeline align-top dense>
<list-item-activity
v-for="activity in groupedTimeline"
:key="activity.time"
:activity="activity"
:activity-group="activity"
class="my-1"
></list-item-activity>
<infinite-loading
v-if="timeline && timeline.items.length < timeline.totalCount"
@infinite="infiniteHandler"
>
<div slot="no-more">This is all your activity!</div>
<div slot="no-results">There are no ctivities to load</div>
</infinite-loading>
</v-timeline>
</div>
</div>
</v-col>
<v-col v-else cols="12">
<no-data-placeholder v-if="quickUser">
<h2>Welcome {{ quickUser.name.split(' ')[0] }}!</h2>
<p class="caption">
Once you will create a stream and start sending some data, your activity will show up
here.
</p>
<template #actions>
<v-list rounded class="transparent">
<v-list-item
link
class="primary mb-4"
dark
@click="$eventHub.$emit('show-new-stream-dialog')"
>
<v-list-item-icon>
<v-icon>mdi-plus-box</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Create a new stream!</v-list-item-title>
<v-list-item-subtitle class="caption">
Streams are like folders, or data repositories.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
</no-data-placeholder>
</v-col>
<v-col
v-show="$vuetify.breakpoint.lgAndUp"
v-if="timeline && timeline.items.length > 0"
cols="12"
lg="4"
class="mt-7"
style="position: sticky; top: 64px"
>
<div class="sticky-top">
<latest-blogposts></latest-blogposts>
</div>
</v-col>
</v-row>
</v-container>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'Timeline',
components: {
InfiniteLoading: () => import('vue-infinite-loading'),
ListItemActivity: () => import('@/components/ListItemActivity'),
LatestBlogposts: () => import('@/components/LatestBlogposts'),
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder')
},
props: {
type: String
},
data() {
return {
newStreamDialog: 0,
activityNav: true
}
},
apollo: {
quickUser: {
query: gql`
query {
quickUser: user {
id
name
}
}
`
},
timeline: {
query: gql`
query($cursor: DateTime) {
user {
id
timeline(cursor: $cursor) {
totalCount
cursor
items {
actionType
userId
streamId
resourceId
resourceType
time
info
message
}
}
}
}
`,
fetchPolicy: 'cache-and-network',
update(data) {
return data.user.timeline
},
result({ data }) {
this.groupSimilarActivities(data)
}
},
streams: {
prefetch: true,
query: gql`
query {
streams(limit: 10) {
items {
id
name
updatedAt
}
}
}
`
}
},
computed: {},
watch: {
timeline(val) {
if (val.totalCount === 0 && !localStorage.getItem('onboarding')) {
this.$router.push('/onboarding')
}
}
},
mounted() {
setTimeout(
function () {
this.activityNav = !this.$vuetify.breakpoint.smAndDown
}.bind(this),
10
)
},
methods: {
groupSimilarActivities(data) {
if (!data) return
let groupedTimeline = data.user.timeline.items.reduce(function (prev, curr) {
//first item
if (!prev.length) {
prev.push([curr])
return prev
}
let test = prev[prev.length - 1][0]
let action = 'split' // split | combine | skip
if (curr.actionType === test.actionType && curr.streamId === test.streamId) {
if (curr.actionType.includes('stream_permissions')) {
//skip multiple stream_permission actions on the same user, just pick the last!
if (prev[prev.length - 1].some((x) => x.info.targetUser === curr.info.targetUser))
action = 'skip'
else action = 'combine'
} //stream, branch, commit
else if (curr.actionType.includes('_update') || curr.actionType === 'commit_create')
action = 'combine'
}
if (action === 'combine') {
prev[prev.length - 1].push(curr)
} else if (action === 'split') {
prev.push([curr])
}
return prev
}, [])
// console.log(groupedTimeline)
this.groupedTimeline = groupedTimeline
},
infiniteHandler($state) {
this.$apollo.queries.timeline.fetchMore({
variables: {
cursor: this.timeline.cursor
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newItems = fetchMoreResult.user.timeline.items
//set vue-infinite state
if (newItems.length === 0) $state.complete()
else $state.loaded()
fetchMoreResult.user.timeline.items = [...previousResult.user.timeline.items, ...newItems]
return fetchMoreResult
}
})
}
}
}
</script>
<style>
.sticky-top {
position: sticky;
top: 68px;
}
.recent-streams a {
text-decoration: none;
}
.recent-streams .v-list-item__title {
font-family: 'Space Grotesk' !important;
}
</style>
-135
View File
@@ -1,135 +0,0 @@
<template lang="html">
<v-container v-if="$apollo.loading" fluid>
<v-skeleton-loader type="article"></v-skeleton-loader>
</v-container>
<v-container v-else-if="isAdmin" :class="`${$vuetify.breakpoint.xsOnly ? 'pl-0' : ''}`">
<v-navigation-drawer
v-model="adminNav"
app
fixed
:permanent="adminNav && !$vuetify.breakpoint.smAndDown"
:style="`${!$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
>
<v-app-bar style="position: absolute; top: 0; width: 100%; z-index: 90" elevation="0">
<v-toolbar-title>Server Admin</v-toolbar-title>
</v-app-bar>
<v-list style="margin-top: 64px; padding-left: 10px" rounded>
<v-list-item link to="/admin/dashboard">
<v-list-item-icon>
<v-icon small class="mt-1">mdi-view-dashboard</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Dashboard</v-list-item-title>
<v-list-item-subtitle class="caption">
Various server stats at a glance.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item link to="/admin/settings">
<v-list-item-icon>
<v-icon small class="mt-1">mdi-tune</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Settings</v-list-item-title>
<v-list-item-subtitle class="caption">
Edit various server settings.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item link to="/admin/users">
<v-list-item-icon>
<v-icon small class="mt-1">mdi-account-group</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Users</v-list-item-title>
<v-list-item-subtitle class="caption">Edit server user details.</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item link to="/admin/invites">
<v-list-item-icon>
<v-icon small class="mt-1">mdi-account-multiple-plus-outline</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Server invites</v-list-item-title>
<v-list-item-subtitle class="caption">Manage server invitations.</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item link to="/admin/streams">
<v-list-item-icon>
<v-icon small class="mt-1">mdi-blur</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Streams</v-list-item-title>
<v-list-item-subtitle class="caption">Manage streams.</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar
v-if="!adminNav"
app
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`"
flat
>
<v-app-bar-nav-icon v-if="!adminNav" @click="adminNav = !adminNav" />
<v-toolbar-title v-if="!adminNav">Server Admin</v-toolbar-title>
</v-app-bar>
<v-container
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px;' : ''} max-width: 1024px;`"
fluid
pt-4
pr-0
>
<transition name="fade">
<router-view></router-view>
</transition>
</v-container>
</v-container>
<v-container v-else-if="!isAdmin">
<error-placeholder error-type="access">
<h2>Only server admins have access to this section.</h2>
</error-placeholder>
</v-container>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'AdminPanel',
components: {
ErrorPlaceholder: () => import('@/components/ErrorPlaceholder')
},
data() {
return {
adminNav: true
}
},
apollo: {
user: {
query: gql`
query {
user {
role
id
}
}
`,
prefetch: true
}
},
computed: {
isAdmin() {
return this.user?.role === 'server:admin'
}
}
}
</script>
@@ -1,233 +0,0 @@
<template>
<v-card>
<v-toolbar flat>
<v-toolbar-title>Send invites to multiple adresses</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-alert v-model="success" prominent timeout="3000" dismissible type="success">
Great! All invites were sent.
</v-alert>
<v-alert v-show="errors.length !== 0" prominent dismissible type="error">
<p>Invite send failed for adresses:</p>
<ul>
<li v-for="error in errors" :key="error.email">{{ error.email }}: {{ error.reason }}</li>
</ul>
</v-alert>
<v-alert
v-show="errors.length !== 0 && sentToEmails.length !== 0"
prominent
timeout="3000"
dismissible
type="success"
>
<p>Invite sent to: {{ sentToEmails.join(', ') }}</p>
</v-alert>
<v-form v-model="valid" @submit.prevent="submit">
<v-textarea
v-model="invitation"
label="Invitation message"
rounded
filled
auto-grow
:rules="validation.messageRules"
></v-textarea>
<v-combobox
v-model="chips"
:search-input.sync="emails"
placeholder="Type emails separated by commas or paste the content of a .csv"
deletable-chips
append-icon=""
filled
rounded
flat
type="email"
class="lighten-2"
:error-messages="inputErrors"
multiple
append-outer-icon="mdi-close"
@keydown="keyDownHandler"
@blur="validateAndCreateChips"
@paste="validateAndCreateChips"
@click:append-outer="chips = []"
>
<template #selection="data">
<v-chip
v-if="data.item"
:input-value="data.selected"
close
@click:close="remove(data.item)"
>
{{ data.item }}
</v-chip>
</template>
</v-combobox>
<p v-if="!selectedStream">Optionaly invite users to stream.</p>
<stream-search-bar
v-if="!selectedStream"
:gotostreamonclick="false"
class="py-3"
@select="setStream"
/>
<v-alert v-else text dense type="info" dismissible @input="dismiss">
They will be invited to be collaborators on {{ selectedStream.name }} stream.
</v-alert>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn :disabled="!submitable" color="primary" type="submit">Invite</v-btn>
</v-card-actions>
</v-form>
</v-card-text>
<v-overlay absolute :value="submitting">
<v-progress-circular :width="1.5" indeterminate></v-progress-circular>
</v-overlay>
</v-card>
</template>
<script>
import gql from 'graphql-tag'
import DOMPurify from 'dompurify'
import StreamSearchBar from '@/components/SearchBar'
import { isEmailValid } from '@/auth-helpers'
export default {
name: 'AdminInvites',
components: { StreamSearchBar },
data() {
return {
valid: false,
success: false,
showError: false,
errors: [],
sentToEmails: [],
submitting: false,
invitation: '',
emails: '',
chips: [],
inputErrors: [],
selectedStream: null,
validation: {
messageRules: [
(v) => {
if (v.length >= 1024) return 'Message too long!'
return true
},
(v) => {
let pure = DOMPurify.sanitize(v)
if (pure !== v) return 'No crazy hacks please.'
else return true
}
]
}
}
},
computed: {
submitable() {
return this.chips && this.chips.length !== 0
}
},
apollo: {
user: {
query: gql`
query {
user {
id
name
}
}
`,
prefetch: true
},
serverInfo: {
query: gql`
query {
serverInfo {
name
canonicalUrl
}
}
`
}
},
methods: {
setStream(stream) {
this.selectedStream = stream
},
dismiss() {
this.selectedStream = null
},
remove(item) {
this.chips.splice(this.chips.indexOf(item), 1)
},
keyDownHandler(val) {
if (!(val.key === ' ' || val.key === ',' || val.key === 'Enter')) return
this.validateAndCreateChips()
},
validateAndCreateChips() {
this.inputErrors = []
if (!this.emails || this.emails === '') return
let splitEmails = this.emails.split(/[ ,]+/)
for (let email of splitEmails) {
let valid = isEmailValid(email) && this.chips.indexOf(email) === -1
if (valid) {
this.chips.push(email)
} else {
this.inputErrors.push('Invalid email')
}
}
this.emails = ''
},
createInviteMessage() {
let message =
`You have been invited to a Speckle server: ${this.serverInfo.name} ` +
`by ${this.user.name}. Visit ${this.serverInfo.canonicalUrl} to register.`
return this.invitation || message
},
async submit() {
this.submitting = true
this.errors = []
this.sentToEmails = []
for (let chip of this.chips) {
if (!chip || chip.length === 0) continue
try {
await this.sendInvite(chip, this.createInviteMessage(), this.selectedStream?.id)
this.sentToEmails.push(chip)
} catch (err) {
this.errors.push({ email: chip, reason: err.graphQLErrors[0].message })
}
}
this.submitting = false
if (this.errors.length === 0) {
this.success = true
this.chips = []
this.dismiss()
}
},
async sendInvite(email, message, streamId) {
let input = {
email: email,
message: message
}
let query = gql`
mutation($input: ${streamId ? 'StreamInviteCreateInput!' : 'ServerInviteCreateInput!'}) {
${streamId ? 'streamInviteCreate' : 'serverInviteCreate'}(input: $input)
}
`
if (streamId) {
input.streamId = streamId
}
await this.$apollo.mutate({
mutation: query,
variables: {
input: input
}
})
this.$matomo && this.$matomo.trackEvent('invite', 'server')
this.$mixpanel.track('Invite Send', { type: 'action', source: 'admin', hostApp: 'web' })
}
}
}
</script>
@@ -1,28 +0,0 @@
<template>
<div id="admin-overview">
<component v-for="comp in cards" :key="comp.name" :is="comp" />
</div>
</template>
<script>
import GeneralInfoCard from '@/components/admin/GeneralInfoCard'
import VersionInfoCard from '@/components/admin/VersionInfoCard'
import ActivityCard from '@/components/admin/ActivityCard'
export default {
name: 'AdminOverview',
data() {
return {
cards: [GeneralInfoCard, ActivityCard, VersionInfoCard]
}
}
}
</script>
<style scoped lang="scss">
#admin-overview {
.v-card:not(:last-child) {
margin-bottom: 1em;
}
}
</style>
@@ -1,129 +0,0 @@
<template>
<div id="admin-settings">
<v-card v-if="serverInfo" rounded="lg">
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`">
<v-toolbar-title>{{ serverInfo.name }}</v-toolbar-title>
</v-toolbar>
<v-card-text>
<div key="viewPanel">
<div v-for="(value, name) in serverDetails" :key="name" class="d-flex align-center mb-2">
<div class="flex-grow-1">
<div v-if="value.type == 'boolean'">
<p class="mt-2">{{ value.label }}</p>
<v-switch
v-model="serverModifications[name]"
inset
persistent-hint
class="pa-1 ma-1 caption"
>
<template #label>
<span class="caption">{{ value.hint }}</span>
</template>
</v-switch>
</div>
<v-text-field
v-else
v-model="serverModifications[name]"
persistent-hint
:hint="value.hint"
class="ma-0 body-2"
></v-text-field>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn block color="primary" :loading="loading" @click="saveEdit">Save</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'ServerInfoAdminCard',
components: {},
data() {
return {
edit: false,
loading: false,
serverModifications: {},
serverDetails: {
name: {
label: 'Name',
hint: "This server's public name"
},
description: {
label: 'Description',
hint: 'A short description of this server'
},
company: {
label: 'Company',
hint: 'The owner of this server'
},
adminContact: {
label: 'Admin contact',
hint: 'The administrator of this server'
},
termsOfService: {
label: 'Terms of service',
hint: 'Url pointing to the terms of service page'
},
inviteOnly: {
label: 'Invite-Only mode',
hint: 'Only users with an invitation will be able to join',
type: 'boolean'
}
}
}
},
apollo: {
serverInfo: {
query: gql`
query {
serverInfo {
name
company
description
adminContact
termsOfService
inviteOnly
}
}
`,
update(data) {
delete data.serverInfo.__typename
this.serverModifications = Object.assign({}, data.serverInfo)
return data.serverInfo
}
}
},
methods: {
async saveEdit() {
this.loading = true
await this.$apollo.mutate({
mutation: gql`
mutation($info: ServerInfoUpdateInput!) {
serverInfoUpdate(info: $info)
}
`,
variables: {
info: this.serverModifications
}
})
await this.$apollo.queries['serverInfo'].refetch()
this.loading = false
}
}
}
</script>
<style scoped lang="scss">
#admin-settings {
.v-card:not(:last-child) {
margin-bottom: 1em;
}
}
</style>
@@ -1,349 +0,0 @@
<template>
<v-card>
<v-toolbar flat>
<v-toolbar-title>
Stream Administration
<span v-if="adminStreams">
({{ adminStreams.items.length }} of {{ adminStreams.totalCount }} streams)
</span>
</v-toolbar-title>
</v-toolbar>
<v-card-subtitle>
<v-text-field
v-model="searchQuery"
class="mx-4 mt-4"
:prepend-inner-icon="'mdi-magnify'"
:loading="$apollo.loading"
label="Search streams"
type="text"
single-line
clearable
rounded
filled
dense
></v-text-field>
<div class="mx-4">
<div class="d-flex">
<v-select
v-model="queryLimit"
:items="[10, 25, 50]"
rounded
dense
filled
flat
label="streams per page"
class="mr-2"
></v-select>
<v-select
v-model="streamVisibility"
:items="['all', 'public', 'private']"
rounded
dense
filled
flat
label="visibility"
class="mr-2"
></v-select>
<v-select
v-model="orderColumn"
:items="['updatedAt', 'size']"
rounded
dense
filled
flat
label="order streams by"
class="mr-2"
></v-select>
<v-btn
fab
elevation="0"
@click="orderDirection = orderDirection === 'desc' ? 'asc' : 'desc'"
>
<v-icon>mdi-arrow-expand-{{ orderDirection === 'desc' ? 'down' : 'up' }}</v-icon>
</v-btn>
</div>
</div>
</v-card-subtitle>
<v-list v-if="!$apollo.loading" rounded>
<v-list-item-group class="ml-6">
<v-list-item v-for="stream in adminStreams.items" :key="stream.id" two-line>
<v-list-item-content>
<v-list-item-title>
<router-link
class="text-h6 text-decoration-none"
:to="`/streams/${stream.id}`"
target="_blank"
>
{{ stream.name }}
</router-link>
</v-list-item-title>
<v-list-item-subtitle>
{{ stream.description ? stream.description : 'Stream has no description' }}
</v-list-item-subtitle>
<div class="mt-1">
<v-chip small>
<v-icon small>
{{ stream.isPublic ? 'mdi-lock-open-variant-outline' : 'mdi-lock-outline' }}
</v-icon>
</v-chip>
<v-chip class="mx-2" small>
Last activity
<timeago :datetime="stream.updatedAt" class="ml-1 mr-"></timeago>
</v-chip>
<v-chip small class="mr-2 pr-5">
<v-icon small class="mr-2">mdi-source-branch</v-icon>
{{ stream.branches.totalCount }}
</v-chip>
<v-chip small class="mr-2">
Data usage {{ `${(stream.size ? stream.size / 1048576 : 0.0).toFixed(2)} MB` }}
</v-chip>
</div>
</v-list-item-content>
<v-list-item-action>
<div
style="cursor: pointer; min-height: 33px; line-height: 33px"
:class="`grey ${$vuetify.theme.dark ? 'darken-3' : 'lighten-3'} rounded-xl px-2`"
>
<user-avatar
v-for="user in stream.collaborators.slice(0, 3)"
v-show="$vuetify.breakpoint.smAndUp"
:id="user.id"
:key="user.id"
:show-hover="false"
:size="25"
></user-avatar>
<v-avatar
v-if="stream.collaborators.length > 3"
v-show="$vuetify.breakpoint.smAndUp"
class="ml-1"
size="25"
color="primary"
>
<span class="white--text caption">+{{ stream.collaborators.length - 3 }}</span>
</v-avatar>
<v-avatar v-show="$vuetify.breakpoint.xsOnly" class="ml-1" size="25" color="primary">
<span class="white--text caption">{{ stream.collaborators.length }}</span>
</v-avatar>
</div>
</v-list-item-action>
<v-list-item-action>
<v-btn icon @click="initiateDeleteStreams(stream)">
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list-item-group>
<div class="text-center">
<v-pagination
v-model="currentPage"
:length="numberOfPages"
:total-visible="7"
circle
></v-pagination>
</div>
</v-list>
<v-skeleton-loader v-else class="mx-auto" type="card"></v-skeleton-loader>
<v-dialog v-model="showDeleteDialog" persistent max-width="600px">
<v-card v-if="showDeleteDialog">
<v-toolbar flat class="mb-6">
<v-toolbar-title>Confirm stream deletion</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-alert type="error">
Confirm deletion of
<b>{{ manipulatedStream.name }}</b>
stream from the server.
<br />
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" text @click="deleteStreams">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import gql from 'graphql-tag'
import UserAvatar from '@/components/UserAvatar'
import debounce from 'lodash.debounce'
export default {
name: 'AdminStreams',
components: { UserAvatar },
props: {
page: { type: [Number, String], required: false, default: null },
limit: { type: [Number, String], required: false, default: 10 },
q: { type: String, required: false, default: null },
orderBy: { type: String, required: false, default: 'updatedAt,desc' },
visibility: { type: String, required: false, default: 'all' }
},
data() {
return {
showDeleteDialog: false,
manipulatedStream: null,
adminStreams: {
items: [],
totalCount: 0
}
}
},
computed: {
numberOfPages() {
return Math.ceil(this.adminStreams.totalCount / this.queryLimit)
},
currentPage: {
get() {
return this.page ? parseInt(this.page) : 1
},
set(page) {
this.navigateNext({ page })
}
},
queryLimit: {
get() {
return this.limit ? parseInt(this.limit) : 10
},
set(limit) {
this.navigateNext({ limit })
}
},
searchQuery: {
get() {
return this.q
},
set: debounce(function (q) {
this.navigateNext({ q })
}, 1000)
},
orderColumn: {
get() {
let [column] = this.orderBy.split(',')
return column
},
set(column) {
let direction = this.orderBy.split(',').pop()
let orderBy = `${column},${direction}`
this.navigateNext({ orderBy })
}
},
orderDirection: {
get() {
return this.orderBy.split(',').pop()
},
set(direction) {
let [column] = this.orderBy.split(',')
let orderBy = `${column},${direction}`
this.navigateNext({ orderBy })
}
},
streamVisibility: {
get() {
return this.visibility
},
set(visibility) {
this.navigateNext({ visibility })
}
}
},
methods: {
initiateDeleteStreams(stream) {
this.showDeleteDialog = true
this.manipulatedStream = stream
},
async deleteStreams() {
let ids = [this.manipulatedStream.id]
await this.$apollo.mutate({
mutation: gql`
mutation($ids: [String!]) {
streamsDelete(ids: $ids)
}
`,
variables: {
ids: ids
},
update: () => {
this.$apollo.queries.adminStreams.refetch()
},
error: (err) => {
console.log(err)
}
})
this.manipulatedStream = null
this.showDeleteDialog = false
},
navigateNext(routeParams) {
this.$router.push(this._prepareRoute(routeParams))
},
_prepareRoute(routeParams) {
let newRoute = 'streams'
let newParams = { ...this.$props }
for (let key in routeParams) {
newParams[key] = routeParams[key]
}
Object.keys(newParams).forEach((attr, index) => {
index === 0 ? (newRoute += '?') : (newRoute += '&')
if (newParams[attr]) newRoute += `${attr}=${newParams[attr]}`
})
return newRoute
}
},
apollo: {
adminStreams: {
query: gql`
query Streams(
$offset: Int
$limit: Int
$orderBy: String
$query: String
$visibility: String
) {
adminStreams(
offset: $offset
limit: $limit
orderBy: $orderBy
query: $query
visibility: $visibility
) {
items {
id
name
description
size
isPublic
createdAt
updatedAt
branches {
totalCount
}
collaborators {
id
name
role
company
avatar
}
}
totalCount
}
}
`,
variables() {
return {
offset: (this.currentPage - 1) * this.queryLimit,
limit: this.queryLimit,
query: this.searchQuery,
orderBy: this.orderBy,
visibility: this.streamVisibility
}
}
}
}
}
</script>
@@ -1,298 +0,0 @@
<template>
<v-card>
<v-toolbar flat>
<v-toolbar-title>
Server users
<span v-if="users">({{ users.items.length }} of {{ users.totalCount }} users)</span>
</v-toolbar-title>
</v-toolbar>
<v-text-field
v-model="searchQuery"
class="mx-4 mt-4"
:prepend-inner-icon="'mdi-magnify'"
:loading="$apollo.loading"
label="Search users"
type="text"
single-line
clearable
rounded
filled
dense
></v-text-field>
<v-list v-if="!$apollo.loading" rounded>
<v-list-item-group v-if="users.totalCount > 0" color="primary">
<v-list-item v-for="user in users.items" :key="user.id">
<v-list-item-avatar class="d-flex justify-start" :size="50">
<user-avatar-icon :avatar="user.avatar" :seed="user.id" :size="50"></user-avatar-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
<router-link class="text-h6" :to="`/profile/${user.id}`" target="_blank">
{{ user.name }}
</router-link>
</v-list-item-title>
<span class="caption">
<v-icon x-small>mdi-email-outline</v-icon>
{{ user.email }} | {{user.verified}}
</span>
<span v-if="user.company" class="caption">
<v-icon x-small>mdi-domain</v-icon>
{{ user.company }}
</span>
<span v-else class="caption">
<v-icon x-small>mdi-help-circle</v-icon>
No company info
</span>
</v-list-item-content>
<v-list-item-action class="pt-2">
<v-select
:value="user.role"
:items="availableRoles"
label="user role"
@change="changeUserRole(user, ...arguments)"
></v-select>
</v-list-item-action>
<v-list-item-action>
<v-btn icon @click="initiateDeleteUser(user)">
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<div class="text-center">
<v-pagination
v-model="currentPage"
:length="numberOfPages"
:total-visible="7"
circle
></v-pagination>
</div>
</v-list-item-group>
</v-list>
<v-skeleton-loader v-else class="mx-auto" type="card"></v-skeleton-loader>
<v-dialog v-model="showConfirmDialog" persistent max-width="600px">
<v-card v-if="showConfirmDialog">
<v-toolbar flat class="mb-6">
<v-toolbar-title>Confirm user role change</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-alert v-if="newRole === 'server:admin'" type="error">
Make sure you trust {{ manipulatedUser.name }}!
<br />
An admin on the server has access to every resource.
</v-alert>
You are changing {{ manipulatedUser.name }}'s server access role from
{{ roleLookupTable[manipulatedUser.role] }} to {{ roleLookupTable[newRole] }}.
<br />
Proceed?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" text @click="cancelRoleChange">Cancel</v-btn>
<v-btn color="primary" text @click="proceedRoleChange">
Change role to {{ roleLookupTable[newRole] }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDeleteDialog" persistent max-width="600px">
<v-card v-if="showDeleteDialog">
<v-toolbar flat class="mb-6">
<v-toolbar-title>Confirm user deletion</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-alert type="error">
Confirm deletion of
<b>{{ manipulatedUser.name }}</b>
's account from the server.
<br />
Streams, where {{ manipulatedUser.name }} is the only owner, will also be deleted.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" text @click="deleteUser(manipulatedUser)">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import gql from 'graphql-tag'
import UserAvatarIcon from '@/components/UserAvatarIcon'
import debounce from 'lodash.debounce'
export default {
name: 'UserAdmin',
components: { UserAvatarIcon },
props: {
limit: { type: [Number, String], required: false, default: 10 },
page: { type: [Number, String], required: false, default: 1 },
q: { type: String, required: false, default: null }
},
data() {
return {
roleLookupTable: {
'server:user': 'User',
'server:admin': 'Admin',
'server:archived-user': 'Archived'
},
users: {
items: [],
totalCount: 0
},
// currentPage: 1,
// searchQuery: null,
showConfirmDialog: false,
showDeleteDialog: false,
manipulatedUser: null,
newRole: null
}
},
computed: {
queryLimit() {
return parseInt(this.limit)
},
currentPage: {
get() {
return parseInt(this.page)
},
set(newPage) {
this.paginateNext(newPage)
}
},
searchQuery: {
get() {
return this.q
},
set: debounce(function (q) {
this.applySearch(q)
}, 500)
},
queryOffset() {
return (this.page - 1) * this.queryLimit
},
numberOfPages() {
return Math.ceil(this.users.totalCount / this.limit)
},
availableRoles() {
let roleItems = []
for (let role in this.roleLookupTable) {
roleItems.push({ text: this.roleLookupTable[role], value: role })
}
return roleItems
}
},
methods: {
initiateDeleteUser(user) {
this.showDeleteDialog = true
this.manipulatedUser = user
},
async deleteUser(user) {
await this.$apollo.mutate({
mutation: gql`
mutation($userEmail: String!) {
adminDeleteUser(userConfirmation: { email: $userEmail })
}
`,
variables: {
userEmail: user.email
},
update: () => {
this.$apollo.queries.users.refetch()
},
error: (err) => {
console.log(err)
}
})
this.resetManipulatedUser()
this.showDeleteDialog = false
},
changeUserRole(user, args) {
this.manipulatedUser = user
this.newRole = args
this.showConfirmDialog = true
},
resetManipulatedUser() {
this.manipulatedUser = null
this.newRole = null
},
cancelRoleChange() {
this.showConfirmDialog = false
this.$apollo.queries.users.refetch()
this.resetManipulatedUser()
},
async proceedRoleChange() {
await this.updateUserRole(this.manipulatedUser.id, this.newRole)
this.resetManipulatedUser()
this.showConfirmDialog = false
},
async updateUserRole(userId, newRole) {
await this.$apollo.mutate({
mutation: gql`
mutation($userId: String!, $newRole: String!) {
userRoleChange(userRoleInput: { id: $userId, role: $newRole })
}
`,
variables: {
userId,
newRole
},
update: () => {
this.$apollo.queries.users.refetch()
},
error: (err) => {
console.log(err)
}
})
},
paginateNext(newPage) {
this.$router.push(this._prepareRoute(newPage, this.limit, this.searchQuery))
},
applySearch(searchQuery) {
this.$router.push(this._prepareRoute(1, this.limit, searchQuery))
},
_prepareRoute(page, limit, query) {
let newRoute = `users?page=${page}&limit=${limit}`
if (query) newRoute = `${newRoute}&q=${query}`
return newRoute
}
},
apollo: {
users: {
query: gql`
query Users($limit: Int, $offset: Int, $query: String) {
users(limit: $limit, offset: $offset, query: $query) {
totalCount
items {
id
suuid
email
name
bio
company
avatar
verified
profiles
role
authorizedApps {
name
}
}
}
}
`,
variables() {
return {
limit: this.queryLimit,
offset: this.queryOffset,
query: this.q
}
}
}
}
}
</script>
@@ -1,379 +0,0 @@
<template>
<div>
<portal to="streamTitleBar">
<div v-if="stream && stream.branch">
<v-icon small class="mr-1">mdi-source-branch</v-icon>
<span class="space-grotesk" style="max-width: 80%">{{ stream.branch.name }}</span>
<span class="caption ml-2 mb-2 pb-2">{{ stream.branch.description }}</span>
<v-chip
v-tooltip="`Branch ${stream.branch.name} has ${stream.branch.commits.totalCount} commits`"
class="ml-2 pl-2"
small
>
<v-icon small>mdi-source-commit</v-icon>
{{ stream.branch.commits.totalCount }}
</v-chip>
</div>
</portal>
<portal to="streamActionsBar">
<v-btn
v-if="
loggedInUserId &&
stream &&
stream.role !== 'stream:reviewer' &&
stream.branch &&
stream.branch.name !== 'main'
"
v-tooltip="'Edit branch'"
elevation="0"
color="primary"
small
rounded
:fab="$vuetify.breakpoint.mdAndDown"
dark
@click="editBranch()"
>
<v-icon small :class="`${$vuetify.breakpoint.mdAndDown ? '' : 'mr-2'}`">mdi-pencil</v-icon>
<span class="hidden-md-and-down">Edit</span>
</v-btn>
</portal>
<v-row no-gutters>
<v-col v-if="stream && stream.branch" cols="12" class="pa-4">
<v-row v-if="stream.branch.commits.items.length > 0">
<v-col cols="12">
<v-card>
<router-link :to="`/streams/${streamId}/commits/${latestCommit.id}`">
<preview-image
:height="320"
:url="`/preview/${$route.params.streamId}/commits/${latestCommit.id}`"
></preview-image>
</router-link>
<div style="position: absolute; top: 10px; right: 20px">
<commit-received-receipts :stream-id="streamId" :commit-id="latestCommit.id" />
</div>
<div style="position: absolute; top: 10px; left: 12px">
<source-app-avatar :application-name="latestCommit.sourceApplication" />
</div>
<v-list-item class="elevation-0">
<v-list-item-icon class="">
<user-avatar
:id="latestCommit.authorId"
:avatar="latestCommit.authorAvatar"
:name="latestCommit.authorName"
:size="40"
/>
</v-list-item-icon>
<v-list-item-content>
<router-link
class="text-decoration-none"
:to="`/streams/${streamId}/commits/${latestCommit.id}`"
>
<v-list-item-title class="mt-0 pt-0 py-1">
{{ latestCommit.message }}
</v-list-item-title>
<v-list-item-subtitle class="caption">
<b>{{ latestCommit.authorName }}</b>
&nbsp;
<timeago :datetime="latestCommit.createdAt"></timeago>
<!-- ({{ commitDate }}) -->
</v-list-item-subtitle>
</router-link>
</v-list-item-content>
</v-list-item>
</v-card>
</v-col>
<v-col cols="12">
<v-toolbar flat class="transparent">
<v-toolbar-title>Older Commits</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
v-tooltip="`View as a ${listMode ? 'gallery' : 'list'}`"
icon
@click="listMode = !listMode"
>
<v-icon v-if="listMode">mdi-view-dashboard</v-icon>
<v-icon v-else>mdi-view-list</v-icon>
</v-btn>
</v-toolbar>
</v-col>
</v-row>
<v-row v-if="!listMode">
<v-col
v-for="commit in allPreviousCommits"
:key="commit.id + 'card'"
cols="12"
sm="6"
md="4"
xl="3"
>
<v-card>
<router-link :to="`/streams/${streamId}/commits/${commit.id}`">
<preview-image
:height="180"
:url="`/preview/${$route.params.streamId}/commits/${commit.id}`"
></preview-image>
</router-link>
<div style="position: absolute; top: 10px; right: 20px">
<commit-received-receipts :stream-id="streamId" :commit-id="commit.id" />
</div>
<div style="position: absolute; top: 10px; left: 12px">
<source-app-avatar :application-name="commit.sourceApplication" />
</div>
<v-list-item class="elevation-0">
<v-list-item-icon class="">
<user-avatar
:id="commit.authorId"
:avatar="commit.authorAvatar"
:name="commit.authorName"
:size="40"
/>
</v-list-item-icon>
<v-list-item-content>
<router-link
class="text-decoration-none"
:to="`/streams/${streamId}/commits/${commit.id}`"
>
<v-list-item-title class="mt-0 pt-0 py-1">
{{ commit.message }}
</v-list-item-title>
<v-list-item-subtitle class="caption">
<b>{{ commit.authorName }}</b>
&nbsp;
<timeago :datetime="commit.createdAt"></timeago>
<!-- ({{ commitDate }}) -->
</v-list-item-subtitle>
</router-link>
</v-list-item-content>
</v-list-item>
</v-card>
</v-col>
</v-row>
</v-col>
<v-col v-if="stream && stream.branch && listMode" cols="12" class="pa-0 ma-0">
<v-list v-if="stream.branch.commits.items.length > 0" class="pa-0 ma-0">
<list-item-commit
v-for="item in allPreviousCommits"
:key="item.id"
:commit="item"
:stream-id="streamId"
show-received-receipts
></list-item-commit>
</v-list>
</v-col>
<infinite-loading
v-if="stream && stream.branch.commits.totalCount !== 0"
spinner="waveDots"
@infinite="infiniteHandler"
>
<div slot="no-more">
<v-col>
<v-toolbar flat class="transparent">
<v-toolbar-title>
You've reached the end - this branch has no more commits.
</v-toolbar-title>
</v-toolbar>
</v-col>
</div>
<div slot="no-results">
<v-col>
<v-toolbar flat class="transparent">
<v-toolbar-title>
You've reached the end - this branch has no more commits.
</v-toolbar-title>
</v-toolbar>
</v-col>
</div>
</infinite-loading>
<!-- <v-col v-if="$apollo.loading">
<v-skeleton-loader type="article, article"></v-skeleton-loader>
</v-col> -->
<branch-edit-dialog ref="editBranchDialog" />
<no-data-placeholder
v-if="!$apollo.loading && stream.branch && stream.branch.commits.totalCount === 0"
>
<h2 class="space-grotesk">Branch "{{ stream.branch.name }}" has no commits.</h2>
</no-data-placeholder>
</v-row>
<error-placeholder
v-if="!$apollo.loading && (error || stream.branch === null)"
error-type="404"
>
<h2>{{ error || `Branch "${$route.params.branchName}" does not exist.` }}</h2>
</error-placeholder>
</div>
</template>
<script>
import gql from 'graphql-tag'
import branchQuery from '@/graphql/branch.gql'
export default {
name: 'Branch',
components: {
InfiniteLoading: () => import('vue-infinite-loading'),
ListItemCommit: () => import('@/components/ListItemCommit'),
BranchEditDialog: () => import('@/components/dialogs/BranchEditDialog'),
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder'),
ErrorPlaceholder: () => import('@/components/ErrorPlaceholder'),
PreviewImage: () => import('@/components/PreviewImage'),
CommitReceivedReceipts: () => import('@/components/CommitReceivedReceipts'),
UserAvatar: () => import('@/components/UserAvatar'),
SourceAppAvatar: () => import('@/components/SourceAppAvatar'),
Renderer: () => import('@/components/Renderer')
},
data() {
return {
dialogEdit: false,
error: null,
listMode: false
}
},
apollo: {
stream: {
query: branchQuery,
variables() {
return {
streamId: this.streamId,
branchName: this.$route.params.branchName.toLowerCase()
}
}
},
$subscribe: {
commitCreated: {
query: gql`
subscription($streamId: String!) {
commitCreated(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.streamId
}
},
result() {
this.$apollo.queries.stream.refetch()
},
error(err) {
console.log(err)
}
},
commitDeleted: {
query: gql`
subscription($streamId: String!) {
commitDeleted(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.streamId
}
},
result() {
this.$apollo.queries.stream.refetch()
},
error(err) {
console.log(err)
}
}
}
},
computed: {
loggedInUserId() {
return localStorage.getItem('uuid')
},
streamId() {
return this.$route.params.streamId
},
latestCommitObjectUrl() {
if (
this.stream &&
this.stream.branch &&
this.stream.branch.commits.items &&
this.stream.branch.commits.items.length > 0
)
return `${window.location.origin}/streams/${this.stream.id}/objects/${this.stream.branch.commits.items[0].referencedObject}`
else return null
},
latestCommit() {
if (this.stream.branch.commits.items && this.stream.branch.commits.items.length > 0)
return this.stream.branch.commits.items[0]
else return null
},
allPreviousCommits() {
if (this.stream.branch.commits.items && this.stream.branch.commits.items.length > 0)
return this.stream.branch.commits.items.slice(1)
else return null
}
},
mounted() {
if (this.$route.params.branchName === 'globals')
this.$router.push(`/streams/${this.$route.params.streamId}/globals`)
},
methods: {
editBranch() {
this.$refs.editBranchDialog.open(this.stream.branch).then((dialog) => {
if (!dialog.result) return
else if (dialog.deleted) {
this.$emit('refetch-branches')
this.$router.push({ path: `/streams/${this.streamId}` })
} else if (dialog.name !== this.$route.params.branchName) {
//this.$router.push does not work, refresh entire window
this.$router.push({
path: `/streams/${this.streamId}/branches/${encodeURIComponent(dialog.name)}`
})
} else {
this.$emit('refetch-branches')
this.$apollo.queries.stream.refetch()
}
})
},
infiniteHandler($state) {
this.$apollo.queries.stream.fetchMore({
variables: {
cursor: this.stream.branch.commits.cursor
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newItems = fetchMoreResult.stream.branch.commits.items
if (newItems.length === 0) $state.complete()
else $state.loaded()
return {
stream: {
__typename: previousResult.stream.__typename,
name: previousResult.stream.name,
id: previousResult.stream.id,
branch: {
id: fetchMoreResult.stream.branch.id,
name: fetchMoreResult.stream.branch.name,
description: fetchMoreResult.stream.branch.description,
__typename: previousResult.stream.branch.__typename,
commits: {
__typename: previousResult.stream.branch.commits.__typename,
cursor: fetchMoreResult.stream.branch.commits.cursor,
totalCount: fetchMoreResult.stream.branch.commits.totalCount,
items: [...previousResult.stream.branch.commits.items, ...newItems]
}
}
}
}
}
})
}
}
}
</script>
<style scoped>
.v-item-group {
float: left;
}
.clear {
clear: both;
}
</style>
@@ -1,375 +0,0 @@
<template>
<v-container fluid style="max-width: 768px">
<portal to="streamTitleBar">
<div>
<v-icon small class="mr-2 hidden-xs-only">mdi-account-multiple</v-icon>
<span class="space-grotesk">Stream Collaborators</span>
</div>
</portal>
<v-alert v-if="stream.role !== 'stream:owner'" type="warning">
Your permission level ({{ stream.role }}) is not high enough to edit this stream's
collaborators.
</v-alert>
<v-card v-if="serverInfo" elevation="0" color="transparent" :class="`mb-4 py-4`">
<v-row align="stretch">
<v-col v-for="role in roles" :key="role.name" cols="12" sm="4">
<v-card
rounded="lg"
style="height: 100%"
:class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''} d-flex flex-column`"
>
<v-toolbar style="flex: none" flat>
<v-toolbar-title class="text-capitalize">
{{ role.name.split(':')[1] }}s
</v-toolbar-title>
<v-spacer></v-spacer>
<v-badge
inline
:content="getRoleCount(role.name)"
:color="`grey ${$vuetify.theme.dark ? 'darken-1' : 'lighten-1'}`"
></v-badge>
</v-toolbar>
<v-card-text class="flex-grow-1">{{ role.description }}</v-card-text>
<v-card-text class="mt-auto">
<div v-if="role.name === 'stream:reviewer'" class="align-self-end">
<user-avatar
v-for="user in reviewers"
:id="user.id"
:key="user.id"
:avatar="user.avatar"
:name="user.name"
:size="30"
/>
<span v-if="reviewers.length === 0">No users with this role.</span>
</div>
<div v-if="role.name === 'stream:contributor'">
<user-avatar
v-for="user in contributors"
:id="user.id"
:key="user.id"
:avatar="user.avatar"
:name="user.name"
:size="30"
/>
<span v-if="contributors.length === 0">No users with this role.</span>
</div>
<div v-if="role.name === 'stream:owner'">
<user-avatar
v-for="user in owners"
:id="user.id"
:key="user.id"
:avatar="user.avatar"
:name="user.name"
:size="30"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card>
<v-card
v-if="stream"
:loading="loading"
elevation="0"
rounded="lg"
:class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`"
>
<template slot="progress">
<v-progress-linear indeterminate></v-progress-linear>
</template>
<v-toolbar
v-if="stream.role === 'stream:owner'"
flat
:class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''}`"
>
<v-toolbar-title>
<v-icon small class="mr-2">mdi-account-plus</v-icon>
<span class="d-inline-block">Add collaborators</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text v-if="stream.role === 'stream:owner'">
<p>
Default role for new collaborators is that of a stream contributor. You will be able to
change it after they're added.
</p>
<v-text-field v-model="search" label="Search for a user" persistent-hint />
<div v-if="$apollo.loading">Searching.</div>
<v-list
v-if="search && search.length >= 3 && userSearch && userSearch.items"
dense
one-line
class="px-0 mx-0 transparent"
>
<v-list-item v-if="filteredSearchResults.length === 0" class="px-0 mx-0">
<v-list-item-content>
<v-list-item-title>
No users found. Note: you can search by name and email.
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="filteredSearchResults.length === 0" class="px-0 mx-0">
<v-list-item-action>
<v-btn color="primary" @click="showStreamInviteDialog">Invite {{ search }}</v-btn>
</v-list-item-action>
</v-list-item>
<v-list-item
v-for="item in filteredSearchResults"
v-else
:key="item.id"
@click="addCollab(item)"
>
<v-list-item-avatar>
<user-avatar
:id="item.id"
:name="item.name"
:avatar="item.avatar"
:size="25"
class="ml-1"
></user-avatar>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle>
{{ item.company ? item.company : 'no company info' }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-icon>mdi-plus</v-icon>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card-text>
<stream-invite-dialog ref="streamInviteDialog" :stream-id="stream.id" :text="search" />
</v-card>
<v-card
v-if="stream"
:loading="loading"
elevation="0"
rounded="lg"
:class="`mt-5 ${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`"
>
<v-toolbar
v-if="stream.role === 'stream:owner'"
flat
:class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''}`"
>
<v-toolbar-title>
<v-icon small class="mr-2">mdi-account-group</v-icon>
<span class="d-inline-block">Edit roles</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text class="px-0">
<p v-if="collaborators.length === 0" class="ml-4">
There are no collaborators on this stream. Speckle is more fun in multiplayer mode, so
invite someone!
</p>
<v-list v-else class="transparent">
<v-list-item v-for="user in collaborators" :key="user.id" two-lines>
<v-list-item-icon>
<user-avatar :id="user.id" :avatar="user.avatar" :name="user.name" :size="42" />
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">{{ user.name }}</v-list-item-title>
<v-list-item-subtitle>
<v-select
v-model="user.role"
item-value="name"
:items="roles"
class="py-0 my-0"
:disabled="stream.role !== 'stream:owner'"
@change="setUserPermissions(user)"
>
<template #selection="{ item }">
{{ item.name }}
</template>
<template #item="{ item }">
<div class="pa-2">
<p class="pa-0 ma-0">{{ item.name }}</p>
<p class="caption pa-0 ma-0 grey--text" style="max-width: 300px">
{{ item.description }}
</p>
</div>
</template>
</v-select>
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
icon
small
color="error"
:disabled="stream.role !== 'stream:owner'"
@click="removeUser(user)"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-container>
</template>
<script>
import gql from 'graphql-tag'
import serverQuery from '@/graphql/server.gql'
import streamCollaboratorsQuery from '@/graphql/streamCollaborators.gql'
import userSearchQuery from '@/graphql/userSearch.gql'
import UserAvatar from '@/components/UserAvatar'
import StreamInviteDialog from '@/components/dialogs/StreamInviteDialog'
export default {
components: { UserAvatar, StreamInviteDialog },
data: () => ({
search: '',
selectedUsers: null,
selectedRole: null,
userSearch: { items: [] },
serverInfo: { roles: [] },
loading: false
}),
apollo: {
stream: {
prefetch: true,
query: streamCollaboratorsQuery,
variables() {
return {
id: this.$route.params.streamId
}
}
},
userSearch: {
query: userSearchQuery,
variables() {
return {
query: this.search,
limit: 25
}
},
skip() {
return !this.search || this.search.length < 3
},
debounce: 300
},
serverInfo: {
prefetch: true,
query: serverQuery
}
},
computed: {
roles() {
return this.serverInfo.roles.filter((x) => x.resourceTarget === 'streams')
},
collaborators() {
if (!this.stream) return []
return this.stream.collaborators.filter((user) => user.id !== this.myId)
},
reviewers() {
if (!this.stream) return []
return this.stream.collaborators.filter((u) => u.role === 'stream:reviewer')
},
contributors() {
if (!this.stream) return []
return this.stream.collaborators.filter((u) => u.role === 'stream:contributor')
},
owners() {
if (!this.stream) return []
return this.stream.collaborators.filter((u) => u.role === 'stream:owner')
},
filteredSearchResults() {
if (!this.userSearch) return null
let users = []
for (let u of this.userSearch.items) {
if (u.id === this.myId) continue
let indx = this.collaborators.findIndex((eu) => eu.id === u.id)
if (indx === -1) users.push(u)
}
return users
},
myId() {
return localStorage.getItem('uuid')
}
},
methods: {
getRoleCount(role) {
if (role === 'stream:owner') return this.owners.length || '0'
if (role === 'stream:contributor') return this.contributors.length || '0'
if (role === 'stream:reviewer') return this.reviewers.length || '0'
},
async removeUser(user) {
this.loading = true
this.$matomo && this.$matomo.trackPageView('stream/remove-collaborator')
this.$mixpanel.track('Permission Action', { type: 'action', name: 'remove', hostApp: 'web' })
try {
await this.$apollo.mutate({
mutation: gql`
mutation streamRevokePermission($params: StreamRevokePermissionInput!) {
streamRevokePermission(permissionParams: $params)
}
`,
variables: {
params: {
streamId: this.stream.id,
userId: user.id
}
}
})
let index = this.stream.collaborators.findIndex((u) => u.id === user.id)
if (index !== -1) {
this.stream.collaborators.splice(index, 1)
}
} catch (e) {
console.log(e)
}
this.$apollo.queries.stream.refetch()
this.loading = false
},
async setUserPermissions(user) {
this.loading = true
await this.grantPermissionUser(user)
this.loading = false
this.$apollo.queries.stream.refetch()
},
async addCollab(user) {
this.loading = true
this.search = null
this.userSearch.items = null
user.role = 'stream:contributor'
await this.grantPermissionUser(user)
this.stream.collaborators.unshift(user)
this.loading = false
this.$apollo.queries.stream.refetch()
},
async grantPermissionUser(user) {
this.$matomo && this.$matomo.trackPageView('stream/add-collaborator')
this.$mixpanel.track('Permission Action', { type: 'action', name: 'add', hostApp: 'web' })
try {
await this.$apollo.mutate({
mutation: gql`
mutation grantPerm($params: StreamGrantPermissionInput!) {
streamGrantPermission(permissionParams: $params)
}
`,
variables: {
params: {
streamId: this.stream.id,
userId: user.id,
role: user.role
}
}
})
} catch (e) {
console.log(e)
}
},
showStreamInviteDialog() {
this.$refs.streamInviteDialog.show()
}
}
}
</script>
@@ -1,263 +0,0 @@
<template>
<div>
<v-row v-if="$apollo.queries.stream.loading" no-gutters>
<v-col cols="12" class="ma-0 pa-0">
<v-card>
<v-skeleton-loader type="list-item-avatar, card-avatar, article"></v-skeleton-loader>
</v-card>
</v-col>
</v-row>
<v-row v-if="stream">
<portal to="streamTitleBar">
<commit-toolbar :stream="stream" @edit-commit="showCommitEditDialog = true" />
</portal>
</v-row>
<div style="height: 100vh; width: 100%; top: -64px; position: absolute">
<renderer :object-url="commitObjectUrl" @selection="handleSelection" />
</div>
<div v-if="stream" cols="12" class="ma-0 pa-0" style="position: relative; top: -64px">
<portal to="nav">
<v-list v-if="stream" style="padding-left: 10px" nav dense class="mt-0 pt-0" expand>
<v-list-item
link
:to="`/streams/${stream.id}/branches/${stream.commit.branchName}`"
class=""
>
<v-list-item-icon>
<v-icon small class>mdi-arrow-left-drop-circle</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">
<v-icon small class="mr-1 caption">mdi-source-branch</v-icon>
{{ stream.commit.branchName }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-icon>
<v-icon small class>mdi-new</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">
TODO: Insert commit menu; mostly viewer based
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
<v-card v-if="false" rounded="lg" style="width: 100%" class="transparent elevation-0">
<!-- Selected object -->
<v-expand-transition>
<v-sheet v-show="selectionData.length !== 0" class="pa-4" color="transparent">
<v-card-title class="mr-8">
<v-badge inline :content="selectionData.length">
<v-icon class="mr-2">mdi-cube</v-icon>
Selection
</v-badge>
</v-card-title>
<div v-if="selectionData.length !== 0">
<object-simple-viewer
v-for="(obj, ind) in selectionData"
:key="obj.id + ind"
:value="obj"
:stream-id="stream.id"
:key-name="`Selected Object ${ind + 1}`"
force-show-open-in-new
force-expand
/>
</div>
</v-sheet>
</v-expand-transition>
<!-- Object explorer -->
<v-card class="pa-4" rounded="lg" color="transparent">
<v-toolbar flat class="transparent">
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-database</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Data</v-toolbar-title>
<v-spacer />
<commit-received-receipts
:stream-id="$route.params.streamId"
:commit-id="stream.commit.id"
/>
</v-toolbar>
<v-card-text class="pa-0">
<object-speckle-viewer
class="mt-4"
:stream-id="stream.id"
:object-id="stream.commit.referencedObject"
:value="commitObject"
:expand="true"
></object-speckle-viewer>
</v-card-text>
</v-card>
</v-card>
</portal>
</div>
<v-row v-if="!$apollo.queries.stream.loading && !stream.commit" justify="center">
<error-placeholder error-type="404">
<h2>Commit {{ $route.params.commitId }} not found.</h2>
</error-placeholder>
</v-row>
<commit-edit-dialog
ref="commitDialog"
@show-delete="showDeleteDialog = true"
></commit-edit-dialog>
<v-dialog v-model="showDeleteDialog" width="500">
<v-card class="pa-0 transparent">
<v-alert type="info" class="ma-0">
<h3>Are you sure?</h3>
You cannot undo this action. This will permanently delete the commit
<v-chip
:to="`/streams/${$route.params.streamId}/commits/${
stream && stream.commit ? stream.commit.id : null
}`"
color="primary"
@click="showDeleteDialog = false"
>
<v-icon small class="mr-2 float-left" light>mdi-timeline-remove-outline</v-icon>
{{ stream && stream.commit ? stream.commit.id : null }}
</v-chip>
<v-divider class="my-3"></v-divider>
<v-btn text class="error--text" @click="deleteCommit">Delete</v-btn>
<v-btn text @click="showDeleteDialog = false">Cancel</v-btn>
</v-alert>
</v-card>
</v-dialog>
</div>
</template>
<script>
import gql from 'graphql-tag'
import streamCommitQuery from '@/graphql/commit.gql'
export default {
name: 'Branch',
components: {
CommitEditDialog: () => import('@/components/dialogs/CommitEditDialog'),
ObjectSpeckleViewer: () => import('@/components/ObjectSpeckleViewer'),
ObjectSimpleViewer: () => import('@/components/ObjectSimpleViewer'),
Renderer: () => import('@/components/Renderer'),
ErrorPlaceholder: () => import('@/components/ErrorPlaceholder'),
CommitReceivedReceipts: () => import('@/components/CommitReceivedReceipts'),
CommitToolbar: () => import('@/cleanup/toolbars/CommitToolbar')
},
data: () => ({
loadedModel: false,
selectionData: [],
showCommitEditDialg: false,
showDeleteDialog: false
}),
apollo: {
stream: {
prefetch: true,
query: streamCommitQuery,
variables() {
return {
streamId: this.$route.params.streamId,
id: this.$route.params.commitId
}
}
}
},
computed: {
loggedInUserId() {
return localStorage.getItem('uuid')
},
commitDate() {
if (!this.stream.commit) return null
let date = new Date(this.stream.commit.createdAt)
let options = { year: 'numeric', month: 'long', day: 'numeric' }
return date.toLocaleString(undefined, options)
},
commitObject() {
return {
speckle_type: 'reference',
referencedId: this.stream?.commit.referencedObject
}
},
commitObjectUrl() {
return `${window.location.origin}/streams/${this.stream?.id}/objects/${this.commitObject.referencedId}`
}
},
watch: {
stream(val) {
if (!val) return
if (val && val.commit && val.commit.branchName && val.commit.branchName === 'globals')
this.$router.push(`/streams/${this.$route.params.streamId}/globals/${val.commit.id}`)
}
},
methods: {
handleSelection(selectionData) {
this.selectionData.splice(0, this.selectionData.length)
this.selectionData.push(...selectionData)
},
editCommit() {
this.$refs.commitDialog.open(this.stream.commit, this.stream.id).then((dialog) => {
if (!dialog.result) return
this.$matomo && this.$matomo.trackPageView('commit/update')
this.$mixpanel.track('Commit Action', { type: 'action', name: 'update', hostApp: 'web' })
this.$apollo
.mutate({
mutation: gql`
mutation commitUpdate($myCommit: CommitUpdateInput!) {
commitUpdate(commit: $myCommit)
}
`,
variables: {
myCommit: { ...dialog.commit }
}
})
.then(() => {
this.$apollo.queries.stream.refetch()
})
.catch((error) => {
// Error
console.error(error)
})
})
},
deleteCommit() {
this.$matomo && this.$matomo.trackPageView('commit/delete')
this.$mixpanel.track('Commit Action', { type: 'action', name: 'delete', hostApp: 'web' })
let commitBranch = null
if (
this.stream &&
this.stream.commit &&
this.stream.commit.branchName &&
this.stream.commit.branchName
)
commitBranch = this.stream.commit.branchName
this.$apollo
.mutate({
mutation: gql`
mutation commitUpdate($myCommit: CommitDeleteInput!) {
commitDelete(commit: $myCommit)
}
`,
variables: {
myCommit: {
streamId: this.stream.id,
id: this.stream.commit.id
}
}
})
.then(() => {
this.$apollo.queries.stream.refetch()
})
.catch((error) => {
// Error
console.error(error)
})
this.showDeleteDialog = false
//window.location.href = window.origin + `/streams/` + this.$route.params.streamId + `/branches/` + commitBranch //go to branch page, refresh all
this.$router.push(`/streams/` + this.$route.params.streamId + `/branches/` + commitBranch)
}
}
}
</script>
<style scoped></style>
@@ -1,269 +0,0 @@
<template>
<div>
<v-row v-if="stream && stream.commits.totalCount !== 0" class="pa-3">
<v-col cols="12" class="pa-4">
<v-card :to="`/streams/${$route.params.streamId}/commits/${stream.commits.items[0].id}`">
<preview-image
:height="320"
:url="`/preview/${$route.params.streamId}/commits/${stream.commits.items[0].id}`"
></preview-image>
<list-item-commit
:commit="stream.commits.items[0]"
:stream-id="$route.params.streamId"
class="elevation-0"
></list-item-commit>
</v-card>
<v-list class="pa-0 ma-0"></v-list>
</v-col>
<v-col cols="12" class="" style="height: 20px"></v-col>
<v-col
cols="12"
:xl="loggedIn ? 4 : 12"
class="pa-0 ma-0"
:order="`${$vuetify.breakpoint.xlOnly ? 'last' : ''}`"
>
<v-card class="transparent elevation-0">
<v-toolbar class="transparent elevation-0">
<v-toolbar-title>Latest Active Branches</v-toolbar-title>
<v-spacer />
<!-- <v-app-bar-nav-icon>
<v-icon>mdi-plus-circle</v-icon>
</v-app-bar-nav-icon> -->
</v-toolbar>
<v-card-title class="caption" style="margin-top: -30px">
The stream's last three updated branches
</v-card-title>
<v-row class="pa-4 mt-0">
<v-col
v-for="branch in latestBranches"
:key="branch.name"
cols="12"
sm="4"
md="4"
:xl="loggedIn ? 12 : 4"
>
<v-card :to="`/streams/${$route.params.streamId}/branches/${branch.name}`">
<preview-image
:height="120"
:url="`/preview/${$route.params.streamId}/commits/${branch.commits.items[0].id}`"
></preview-image>
<v-toolbar flat class="transparent">
<v-toolbar-title>
<v-icon>mdi-source-branch</v-icon>
{{ branch.name }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-badge
inline
:content="branch.commits.totalCount"
:color="`grey ${$vuetify.theme.dark ? 'darken-1' : 'lighten-1'}`"
></v-badge>
</v-toolbar>
<list-item-commit
:commit="branch.commits.items[0]"
:stream-id="$route.params.streamId"
></list-item-commit>
</v-card>
</v-col>
</v-row>
</v-card>
</v-col>
<v-col v-if="loggedIn" cols="12" xl="8" class="pr-4">
<v-card class="transparent elevation-0">
<v-toolbar class="transparent elevation-0">
<v-toolbar-title>Stream Feed</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-card-title class="caption" style="margin-top: -30px">Recent activity log</v-card-title>
</v-card>
<div style="margin-top: -42px">
<stream-activity></stream-activity>
</div>
</v-col>
</v-row>
<no-data-placeholder v-if="stream && stream.commits.totalCount === 0">
<h2>This stream has not received any data.</h2>
<p class="caption">
Streams are repositories where you can store, version and retrieve various design data.
</p>
</no-data-placeholder>
</div>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'Details',
components: {
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder'),
ListItemCommit: () => import('@/components/ListItemCommit'),
PreviewImage: () => import('@/components/PreviewImage'),
StreamActivity: () => import('@/views/stream/Activity')
},
data() {
return {
clearRendererTrigger: 0,
error: '',
selectedBranch: null
}
},
apollo: {
stream: {
query: gql`
query Stream($id: String!) {
stream(id: $id) {
id
isPublic
name
branches {
totalCount
items {
name
description
commits(limit: 1) {
totalCount
items {
id
createdAt
message
referencedObject
authorId
authorName
authorAvatar
sourceApplication
}
}
}
}
commits(limit: 1) {
totalCount
items {
id
authorName
authorId
authorAvatar
sourceApplication
message
referencedObject
createdAt
branchName
}
}
}
}
`,
variables() {
return {
id: this.$route.params.streamId
}
},
error(err) {
if (err.message) this.error = err.message.replace('GraphQL error: ', '')
else this.error = err
}
},
$subscribe: {
streamUpdated: {
query: gql`
subscription($streamId: String!) {
streamUpdated(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.$route.params.streamId
}
},
result() {
this.$apollo.queries.stream.refetch()
},
skip() {
return !this.loggedIn
}
},
branchCreated: {
query: gql`
subscription($streamId: String!) {
branchCreated(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.$route.params.streamId
}
},
result() {
this.$apollo.queries.stream.refetch()
},
skip() {
return !this.loggedIn
}
},
branchDeleted: {
query: gql`
subscription($streamId: String!) {
branchDeleted(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.$route.params.streamId
}
},
result() {
this.$apollo.queries.stream.refetch()
},
skip() {
return !this.loggedIn
}
},
commitCreated: {
query: gql`
subscription($streamId: String!) {
commitCreated(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.$route.params.streamId
}
},
result() {
this.$apollo.queries.stream.refetch()
},
skip() {
return !this.loggedIn
}
}
}
},
computed: {
latestCommitObjectUrl() {
if (this.stream && this.stream.commits.items.length > 0)
return `${window.location.origin}/streams/${this.stream.id}/objects/${this.stream.commits.items[0].referencedObject}`
else return null
},
latestBranches() {
if (!this.stream) return []
let branches = this.stream.branches.items
.filter((br) => br.name !== 'globals' && br.commits.totalCount !== 0)
.slice()
.sort(
(a, b) => new Date(b.commits.items[0].createdAt) - new Date(a.commits.items[0].createdAt)
)
return branches.slice(0, 3)
},
loggedIn() {
return localStorage.getItem('uuid') !== null
}
},
watch: {},
methods: {}
}
</script>
@@ -1,275 +0,0 @@
<template>
<div>
<portal to="toolbar">
<div class="d-flex align-center">
<div class="text-truncate">
<router-link
v-tooltip="stream.name"
class="text-decoration-none space-grotesk mx-1"
:to="`/streams/${stream.id}`"
>
<v-icon small class="primary--text mb-1 mr-1">mdi-folder</v-icon>
<b>{{ stream.name }}</b>
</router-link>
</div>
<div class="text-truncate flex-shrink-0">
/
<v-icon small class="mx-1 mb-1 hidden-xs-only">mdi-earth</v-icon>
<span class="space-grotesk" style="max-width: 80%">Globals Variables</span>
</div>
</div>
</portal>
<no-data-placeholder
v-if="!objectId && !$apollo.loading && !revealBuilder"
:show-message="false"
>
<h2>There are no global variables in this stream.</h2>
<p class="caption">
Global variables can hold various information that's useful across the project: location
(city, adress, lat & long coordinates), custom project names or tags, or any other numbers
or text that you want to keep track of.
</p>
<template #actions>
<v-list rounded class="transparent">
<v-list-item
v-if="stream.role !== 'stream:reviewer'"
link
class="primary mb-4"
dark
@click="createGlobals()"
>
<v-list-item-icon>
<v-icon>mdi-plus-box</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Create Globals</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-else class="warning" dark>
<v-list-item-icon>
<v-icon small>mdi-lock</v-icon>
</v-list-item-icon>
<v-list-item-content class="caption">
You do not have enough permissions to create globals.
</v-list-item-content>
</v-list-item>
<v-list-item
link
:class="`grey ${$vuetify.theme.dark ? 'darken-4' : 'lighten-4'} mb-4`"
href="https://speckle.guide/user/web.html#globals"
target="_blank"
>
<v-list-item-icon>
<v-icon>mdi-book-open-variant</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Globals docs</v-list-item-title>
<v-list-item-subtitle class="caption">
Read the documentation on global variables.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
</no-data-placeholder>
<div v-if="objectId || revealBuilder">
<v-row>
<!-- Help -->
<v-col cols="12">
<v-card
v-if="true"
elevation="0"
rounded="lg"
:class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`"
>
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''} mb-2`">
<v-toolbar-title>
<v-icon class="mr-2" small>mdi-earth</v-icon>
<span class="d-inline-block">What are Globals?</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<p class="caption">
Globals are useful for storing design values, project requirements, notes, or any
info you want to keep track of alongside your geometry. Read more on stream global
variables
<a href="https://speckle.guide/user/web.html#globals" target="_blank">here</a>
.
<v-divider class="my-2"></v-divider>
<b>Global editor help:</b>
Drag and drop fields in and out of groups as you please. Click the box icon next to
any field to turn it into a nested group of fields.
</p>
</v-card-text>
<v-alert
v-if="!(stream.role === 'stream:contributor') && !(stream.role === 'stream:owner')"
class="my-3"
dense
type="warning"
>
You are free to play around with the globals here, but you do not have the required
stream permission to save your changes.
</v-alert>
</v-card>
</v-col>
<!-- History -->
<v-col cols="12" md="4">
<v-card
v-if="!$apollo.loading"
elevation="0"
rounded="lg"
:class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''} pa-0`"
style="overflow: hidden"
>
<v-toolbar
class="elevation-"
flat
:class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''}`"
>
<v-toolbar-title style="cursor: pointer" @click="showHistory = !showHistory">
<v-icon small class="mr-2">mdi-history</v-icon>
Globals History ({{ branch.commits.totalCount }})
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon class="mr-1" @click="showHistory = !showHistory">
<v-icon>
{{ !showHistory ? 'mdi-chevron-down' : 'mdi-chevron-up' }}
</v-icon>
</v-btn>
</v-toolbar>
<v-list
v-show="showHistory"
v-if="branch.commits.totalCount !== 0"
class="pa-0 transparent"
dense
>
<list-item-commit
v-for="item in branch.commits.items"
:key="item.id"
:route="`/streams/${streamId}/globals/${item.id}`"
:commit="item"
:stream-id="streamId"
/>
</v-list>
<div v-else class="pa-2">No globals saved yet.</div>
</v-card>
</v-col>
<v-col cols="12" md="8">
<!-- Builder -->
<globals-builder
:branch-name="branchName"
:stream-id="streamId"
:object-id="objectId"
:commit-message="commit ? commit.message : null"
:user-role="stream.role"
@new-commit="newCommit"
/>
</v-col>
</v-row>
</div>
</div>
</template>
<script>
import gql from 'graphql-tag'
import branchQuery from '@/graphql/branch.gql'
export default {
name: 'Globals',
components: {
GlobalsBuilder: () => import('@/components/GlobalsBuilder'),
ListItemCommit: () => import('@/components/ListItemCommit'),
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder')
},
apollo: {
stream: {
query: gql`
query Stream($id: String!) {
stream(id: $id) {
id
name
role
}
}
`,
variables() {
return {
id: this.$route.params.streamId
}
}
},
branch: {
query: branchQuery,
variables() {
return {
streamId: this.streamId,
branchName: this.branchName
}
},
update(data) {
return data.stream.branch
}
}
},
data() {
return {
branchName: 'globals', //TODO: handle multipile globals branches,
revealBuilder: false,
loading: false,
showHistory: true
}
},
computed: {
streamId() {
return this.$route.params.streamId
},
commit() {
return this.$route.params.commitId
? this.branch?.commits?.items?.filter((c) => c.id == this.$route.params.commitId)[0]
: this.branch?.commits?.items[0]
},
objectId() {
return this.commit?.referencedObject
}
},
methods: {
async createGlobals() {
if (!this.branch) {
this.loading = true
this.$matomo && this.$matomo.trackPageView('globals/branch/create')
this.$mixpanel.track('Globals Action', { type: 'action', name: 'create', hostApp: 'web' })
await this.$apollo.mutate({
mutation: gql`
mutation branchCreate($params: BranchCreateInput!) {
branchCreate(branch: $params)
}
`,
variables: {
params: {
streamId: this.streamId,
name: 'globals',
description: 'Stream globals'
}
}
})
this.$apollo.queries.branch.refetch()
this.loading = false
}
this.revealBuilder = true
},
newCommit() {
this.$apollo.queries.branch.refetch()
if (this.$route.params.commitId) this.$router.push(`/streams/${this.streamId}/globals`)
}
}
}
</script>
<style scoped></style>
@@ -1,83 +0,0 @@
<template>
<v-row no-gutters>
<portal to="streamTitleBar">
<div>
<v-icon small class="mr-1">mdi-database</v-icon>
<span class="space-grotesk mr-2">Object View</span>
</div>
</portal>
<v-col cols="12" sm="12" class="ma-0 pa-0">
<v-card class="pa-0" elevation="0" rounded="lg" color="transparent" style="height: 60vh">
<renderer :object-url="commitObjectUrl" @selection="handleSelection" />
</v-card>
<v-card class="pa-4" elevation="0" rounded="lg">
<v-expand-transition>
<v-sheet v-show="selectionData.length !== 0" class="pa-0" color="transparent">
<v-card-title class="mr-8">
<v-badge inline :content="selectionData.length">
<v-icon class="mr-2">mdi-cube</v-icon>
Selection
</v-badge>
</v-card-title>
<div v-if="selectionData.length !== 0">
<object-simple-viewer
v-for="(obj, ind) in selectionData"
:key="obj.id + ind"
:value="obj"
:stream-id="$route.params.streamId"
:key-name="`Selected Object ${ind + 1}`"
force-show-open-in-new
force-expand
/>
</div>
</v-sheet>
</v-expand-transition>
<v-card-title class="mr-8">
<v-icon class="mr-2">mdi-database</v-icon>
Object {{ $route.params.objectId }}
</v-card-title>
<v-card-text class="pa-0">
<object-speckle-viewer
class="mt-4"
:stream-id="$route.params.streamId"
:value="commitObject"
:expand="true"
></object-speckle-viewer>
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script>
import ObjectSpeckleViewer from '@/components/ObjectSpeckleViewer'
import ObjectSimpleViewer from '@/components/ObjectSimpleViewer'
import Renderer from '@/components/Renderer'
export default {
name: 'ObjectViewer',
components: { ObjectSimpleViewer, ObjectSpeckleViewer, Renderer },
data() {
return {
selectionData: []
}
},
computed: {
commitObject() {
return {
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: this.$route.params.objectId
}
},
commitObjectUrl() {
return `${window.location.origin}/streams/${this.$route.params.streamId}/objects/${this.$route.params.objectId}`
}
},
methods: {
handleSelection(selectionData) {
this.selectionData.splice(0, this.selectionData.length)
this.selectionData.push(...selectionData)
}
}
}
</script>
@@ -1,262 +0,0 @@
<template>
<v-container style="max-width: 768px">
<portal to="streamTitleBar">
<div>
<v-icon small class="mr-2 hidden-xs-only">mdi-cog</v-icon>
<span class="space-grotesk">Settings</span>
</div>
</portal>
<v-alert v-if="stream.role !== 'stream:owner'" type="warning">
Your permission level ({{ stream.role }}) is not high enough to edit this stream's details.
</v-alert>
<v-card
:class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`"
elevation="0"
rounded="lg"
:loading="loading"
>
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''} mb-2`">
<v-toolbar-title>
<v-icon class="mr-2" small>mdi-cog</v-icon>
<span class="d-inline-block">General</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-form ref="form" v-model="valid" class="px-2" @submit.prevent="save">
<v-text-field
v-model="name"
:rules="validation.nameRules"
label="Name"
hint="The name of this stream."
class="mt-5"
:disabled="stream.role !== 'stream:owner'"
/>
<v-text-field
v-model="description"
label="Description"
hint="The description of this stream."
class="mt-5"
:disabled="stream.role !== 'stream:owner'"
/>
<v-switch
v-model="isPublic"
inset
class="mt-5"
:label="isPublic ? 'Public (Link Sharing)' : 'Private'"
:hint="
isPublic
? 'Anyone with the link can view this stream. It is also visible on your profile page. Only collaborators can push data to it.'
: 'Only collaborators can access this stream.'
"
persistent-hint
:disabled="stream.role !== 'stream:owner'"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn class="ml-3" color="primary" type="submit" :disabled="!canSave" @click="save">
Save Changes
</v-btn>
</v-card-actions>
</v-card>
<v-card
:class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''} mt-2`"
elevation="0"
rounded="lg"
>
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''} mb-2`">
<v-toolbar-title>
<v-icon class="mr-2" small>mdi-bomb</v-icon>
<span class="d-inline-block">Danger Zone</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-list-item three-line>
<v-list-item-action>
<v-btn
color="error"
fab
dark
small
:disabled="stream.role !== 'stream:owner'"
@click="deleteDialog = true"
>
<v-icon>mdi-delete-forever</v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Permanently Delete Stream</v-list-item-title>
<v-list-item-subtitle>
Once you delete a stream, there is no going back! All data will be removed, and
existing collaborators will not be able to access it.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-dialog v-model="deleteDialog" width="500" @keydown.esc="deleteDialog = false">
<v-card>
<v-toolbar class="error mb-4">
<v-toolbar-title>Deleting Stream '{{ stream.name }}'</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn icon @click="deleteDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-text>
Type the name of the stream below to confirm you really want to delete it. All data
will be removed, and existing collaborators will not be able to access it.
<v-divider class="my-2"></v-divider>
<b>You cannot undo this action.</b>
<v-text-field
v-model="streamNameConfirm"
label="Confirm stream name"
class="pt-10"
></v-text-field>
</v-card-text>
<v-card-actions>
<!-- <v-btn text color="primary" @click="deleteDialog = false">Cancel</v-btn> -->
<v-btn
block
class="mr-3"
color="error"
:disabled="streamNameConfirm !== stream.name"
@click="deleteStream"
>
delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card-text>
</v-card>
<v-snackbar v-model="snackbar" timeout="800" color="primary">
<p class="text-center my-0">
<b>Changes saved!</b>
</p>
</v-snackbar>
</v-container>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'StreamSettings',
components: {},
apollo: {
stream: {
query: gql`
query Stream($id: String!) {
stream(id: $id) {
id
name
description
isPublic
role
}
}
`,
variables() {
return {
id: this.$route.params.streamId
}
},
update(data) {
let stream = data.stream
if (stream)
({ name: this.name, description: this.description, isPublic: this.isPublic } = stream)
return stream
}
}
},
data: () => ({
snackbar: false,
loading: false,
loadingDelete: false,
valid: false,
name: null,
deleteDialog: false,
streamNameConfirm: '',
description: null,
isPublic: true,
validation: {
nameRules: [(v) => !!v || 'A stream must have a name!']
}
}),
computed: {
canSave() {
return (
this.stream.role === 'stream:owner' &&
this.valid &&
(this.name !== this.stream.name ||
this.description !== this.stream.description ||
this.isPublic !== this.stream.isPublic)
)
}
},
methods: {
async save() {
this.loading = true
this.$matomo && this.$matomo.trackPageView('stream/update')
this.$mixpanel.track('Stream Action', { type: 'action', name: 'update', hostApp: 'web' })
try {
await this.$apollo.mutate({
mutation: gql`
mutation editDescription($input: StreamUpdateInput!) {
streamUpdate(stream: $input)
}
`,
variables: {
input: {
id: this.stream.id,
name: this.name,
description: this.description,
isPublic: this.isPublic
}
}
})
this.snackbar = true
} catch (e) {
console.log(e)
}
this.$apollo.queries.stream.refetch()
this.loading = false
},
async deleteStream() {
this.$matomo && this.$matomo.trackPageView('stream/delete')
this.$mixpanel.track('Stream Action', { type: 'action', name: 'delete', hostApp: 'web' })
this.loadingDelete = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation deleteStream($id: String!) {
streamDelete(id: $id)
}
`,
variables: {
id: this.stream.id
}
})
} catch (e) {
console.log(e)
}
this.deleteDialog = false
this.$router.push({ path: '/streams' })
}
}
}
</script>
@@ -1,832 +0,0 @@
<template>
<v-container fluid pa-0 ma-0>
<!-- Stream Page Navigation Drawer -->
<v-navigation-drawer
v-if="!error"
v-model="streamNav"
app
fixed
clipped
:permanent="streamNav && !$vuetify.breakpoint.smAndDown"
:style="`${!$vuetify.breakpoint.xsOnly ? 'left: 56px' : ''}`"
>
<!-- Toolbar holds link to stream home page -->
<v-app-bar
v-if="stream && $vuetify.breakpoint.smAndDown"
style="position: absolute; top: 0; width: 100%; z-index: 90"
elevation="0"
flat
>
<v-toolbar-title>
<router-link
v-tooltip="stream.name"
:to="`/streams/${stream.id}`"
class="text-decoration-none space-grotesk"
>
<v-icon class="mr-2 primary--text" style="font-size: 20px">mdi-folder</v-icon>
<b>{{ stream.name }}</b>
</router-link>
</v-toolbar-title>
</v-app-bar>
<!-- <v-skeleton-loader v-else type="list-item-two-line"></v-skeleton-loader> -->
<!-- Top padding hack -->
<div v-if="$vuetify.breakpoint.smAndDown" style="display: block; height: 65px"></div>
<div v-if="!loggedIn" class="px-4 mt-2">
<v-btn large block color="primary" to="/authn/login">Log In</v-btn>
</div>
<!-- Various Stream Details -->
<v-card v-if="stream" elevation="0" class="pa-1 mb-0" color="transparent">
<v-card-text class="caption">
<span v-html="parsedDescription"></span>
<router-link
v-if="stream.role === 'stream:owner'"
:to="`/streams/${$route.params.streamId}/settings`"
class="text-decoration-none"
>
Edit
</router-link>
<v-divider class="my-2"></v-divider>
<div class="caption">
<span v-tooltip="formatDate(stream.createdAt)">
Created
<timeago :datetime="stream.createdAt"></timeago>
</span>
,
<span v-tooltip="formatDate(stream.updatedAt)">
Updated
<timeago :datetime="stream.updatedAt"></timeago>
</span>
</div>
<v-divider class="my-2"></v-divider>
<div>
<!--
Note: the current layout fits either:
- 5 x (collab avatars) + (manage collabs button), or
- 4 x (collab avatars) + ( extra collabs info number ) + (manage collabs button)
-->
<user-avatar
v-for="collab in stream.collaborators.slice(
0,
stream.collaborators.length > 5 ? 4 : 5
)"
:id="collab.id"
:key="collab.id"
:size="30"
:avatar="collab.avatar"
:name="collab.name"
></user-avatar>
<v-btn
v-if="stream.collaborators.length > 5"
v-tooltip="`${stream.collaborators.length - 4} more collaborators`"
icon
:to="`/streams/${stream.id}/collaborators`"
>
<span class="text-subtitle-1">+{{ stream.collaborators.length - 4 }}</span>
</v-btn>
<v-btn
v-if="stream.collaborators.length <= 5"
v-tooltip="'Manage collaborators'"
icon
:to="`/streams/${stream.id}/collaborators`"
class="ml-2"
>
<v-avatar>
<v-icon>mdi-account-plus</v-icon>
</v-avatar>
</v-btn>
</div>
<!-- Your role: {{ stream.role }} -->
<v-divider class="my-2"></v-divider>
<v-chip small class="mr-2">{{ stream.commits.totalCount }} Commits</v-chip>
<v-chip small class="mr-2">{{ branchesTotalCount }} Branches</v-chip>
</v-card-text>
</v-card>
<!-- Stream menu options -->
<v-list v-if="stream" style="padding-left: 10px" rounded dense class="mt-0 pt-0" expand>
<v-list-item link :to="`/streams/${stream.id}`" class="no-overlay">
<v-list-item-icon>
<v-icon small>mdi-home</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Stream Home</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Branch menu group -->
<v-list-group v-model="branchMenuOpen" class="my-2">
<template #activator>
<v-list-item-icon>
<v-icon small>mdi-source-branch</v-icon>
</v-list-item-icon>
<v-list-item-title>Branches ({{ branchesTotalCount }})</v-list-item-title>
</template>
<v-divider class="mb-1"></v-divider>
<v-list-item
v-if="stream.role !== 'stream:reviewer'"
v-tooltip.bottom="'Create a new branch to help categorise your commits.'"
link
@click="showNewBranchDialog()"
>
<v-list-item-icon>
<v-icon small style="padding-top: 10px" class="primary--text">mdi-plus-box</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>New Branch</v-list-item-title>
<v-list-item-subtitle class="caption">
Create a new branch to help categorise your commits.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- TODO -->
<div v-if="!$apollo.queries.branchQuery.loading">
<template v-for="(item, i) in groupedBranches">
<v-list-item
v-if="item.type === 'item'"
:key="i"
:to="`/streams/${stream.id}/branches/${item.name}`"
exact
>
<v-list-item-icon>
<v-icon v-if="item.name !== 'main'" small style="padding-top: 10px">
mdi-source-branch
</v-icon>
<v-icon v-else small style="padding-top: 10px" class="primary--text">
mdi-star
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ item.displayName }} ({{ item.commits.totalCount }})
</v-list-item-title>
<v-list-item-subtitle class="caption">
{{ item.description ? item.description : 'no description' }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-group
v-else
:key="i"
sub-group
:value="item.expand"
prepend-icon=""
:group="item.name"
>
<template #activator>
<v-list-item style="overflow: visible">
<v-list-item-icon style="position: relative; left: -26px">
<v-icon style="padding-top: 10px">
{{ item.expand ? 'mdi-chevron-down' : 'mdi-chevron-down' }}
</v-icon>
</v-list-item-icon>
<v-list-item-content style="position: relative; left: -8px">
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle class="caption">
{{ item.children.length }} branches
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
<v-list-item
v-for="(kid, j) in item.children"
:key="j"
:to="`/streams/${stream.id}/branches/${kid.name}`"
exact
>
<v-list-item-icon>
<v-icon small style="padding-top: 10px">mdi-source-branch</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ kid.displayName }} ({{ kid.commits.totalCount }})
</v-list-item-title>
<v-list-item-subtitle class="caption">
{{ kid.description ? kid.description : 'no description' }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list-group>
</template>
</div>
<v-skeleton-loader v-else type="list-item-two-line"></v-skeleton-loader>
<v-divider class="mb-2"></v-divider>
</v-list-group>
<!-- Other menu items go here -->
<v-list-item link :to="`/streams/${stream.id}/globals`">
<v-list-item-icon>
<v-icon small>mdi-earth</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Globals</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item link :to="`/streams/${stream.id}/uploads`">
<v-list-item-icon>
<v-icon small>mdi-arrow-up</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Import IFC</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item link :to="`/streams/${stream.id}/webhooks`">
<v-list-item-icon>
<v-icon small>mdi-webhook</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Webhooks</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item link :to="`/streams/${stream.id}/collaborators`">
<v-list-item-icon>
<v-icon small>mdi-account-group</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Collaborators</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item link :to="`/streams/${stream.id}/settings`">
<v-list-item-icon>
<v-icon small>mdi-cog</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
<!-- Stream Page App Bar -->
<v-app-bar
v-if="!error"
app
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`"
flat
clipped-left
>
<v-app-bar-nav-icon v-if="true || !streamNav" @click="streamNav = !streamNav">
<!-- <v-icon v-if="streamNav">mdi-chevron-left</v-icon> -->
</v-app-bar-nav-icon>
<v-toolbar-title class="pl-0">
<router-link
v-if="stream"
v-show="true || (!streamNav && !$vuetify.breakpoint.smAndDown)"
class="text-decoration-none space-grotesk"
:to="`/streams/${stream.id}`"
>
<b>{{ stream.name }}</b>
</router-link>
<span v-show="true || (!streamNav && !$vuetify.breakpoint.smAndDown)" class="mx-2">/</span>
<portal-target name="streamTitleBar" slim style="display: inline-block">
<!-- child routes can teleport things here -->
</portal-target>
</v-toolbar-title>
<v-spacer></v-spacer>
<portal-target name="streamActionsBar">
<!-- child routes can teleport buttons here -->
</portal-target>
<v-toolbar-items style="margin-right: -20px">
<v-btn v-if="!loggedIn && stream && !streamNav" large color="primary" to="/authn/login">
Log In
</v-btn>
<v-btn
v-if="loggedIn && stream"
v-tooltip="'Share this stream'"
elevation="0"
@click="openShareStreamDialog()"
>
<v-icon v-if="!stream.isPublic" small class="mr-2 grey--text">mdi-lock</v-icon>
<v-icon v-else small class="mr-2 grey--text">mdi-lock-open</v-icon>
<v-icon small class="mr-2">mdi-share-variant</v-icon>
<span class="hidden-md-and-down">Share</span>
</v-btn>
</v-toolbar-items>
</v-app-bar>
<!-- Stream Child Routes -->
<v-container
v-if="!error"
:style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px;' : ''}`"
:class="`${$vuetify.breakpoint.xsOnly ? 'pl-0' : ''}`"
fluid
pt-0
pr-0
>
<transition name="fade">
<router-view v-if="stream" @refetch-branches="refetchBranches"></router-view>
</transition>
</v-container>
<v-container v-else :style="`${!$vuetify.breakpoint.xsOnly ? 'padding-left: 56px' : ''}`">
<error-placeholder :error-type="error.toLowerCase().includes('not found') ? '404' : 'access'">
<h2>{{ error }}</h2>
</error-placeholder>
</v-container>
<branch-new-dialog ref="branchDialog" @refetch-branches="refetchBranches" />
<v-dialog v-model="shareStream" max-width="600" :fullscreen="$vuetify.breakpoint.xsOnly">
<v-card>
<v-sheet color="primary">
<v-toolbar color="primary" dark flat>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-share-variant</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Engage Multiplayer Mode!</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="shareStream = false"><v-icon>mdi-close</v-icon></v-btn>
</v-toolbar>
<v-card-text class="mt-0 mb-0 px-2">
<v-text-field
ref="streamUrl"
dark
filled
rounded
hint="Stream url copied to clipboard. Use it in a connector, or just share it with colleagues!"
style="color: blue"
prepend-inner-icon="mdi-folder"
:value="streamUrl"
@focus="copyToClipboard"
></v-text-field>
<v-text-field
v-if="$route.params.branchName"
ref="branchUrl"
dark
filled
rounded
hint="Branch url copied to clipboard. Most connectors can receive the latest commit from a branch by using this url."
style="color: blue"
prepend-inner-icon="mdi-source-branch"
:value="streamUrl + '/branches/' + $route.params.branchName"
@focus="copyToClipboard"
></v-text-field>
<v-text-field
v-if="$route.params.commitId"
ref="commitUrl"
dark
filled
rounded
hint="Commit url copied to clipboard. Most connectors can receive a specific commit by using this url."
style="color: blue"
prepend-inner-icon="mdi-source-commit"
:value="streamUrl + '/commits/' + $route.params.commitId"
@focus="copyToClipboard"
></v-text-field>
</v-card-text>
</v-sheet>
<v-sheet
v-if="stream"
:class="`${!$vuetify.theme.dark ? 'grey lighten-4' : 'grey darken-4'}`"
>
<v-toolbar v-if="stream.role === 'stream:owner'" class="transparent" rounded flat>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>{{ stream.isPublic ? 'mdi-lock-open' : 'mdi-lock' }}</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>
{{ stream.isPublic ? 'Public stream' : 'Private stream' }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-switch
v-model="stream.isPublic"
inset
class="mt-4"
:loading="swapPermsLoading"
@click="changeVisibility"
></v-switch>
</v-toolbar>
<v-card-text v-if="stream.isPublic" class="pt-2">
This stream is public. This means that anyone with the link can view and read data from
it.
</v-card-text>
<v-card-text v-if="!stream.isPublic" class="pt-2 pb-2">
This stream is private. This means that only collaborators can access it.
</v-card-text>
</v-sheet>
<v-sheet v-if="stream">
<v-toolbar
v-tooltip="
`${
stream.role !== 'stream:owner'
? 'You do not have the right access level (' +
stream.role +
') to add collaborators.'
: ''
}`
"
flat
>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-account-group</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>
Collaborators
<user-avatar
v-for="collab in stream.collaborators.slice(
0,
stream.collaborators.length > 5 ? 4 : 5
)"
:id="collab.id"
:key="collab.id"
:size="20"
:avatar="collab.avatar"
:name="collab.name"
></user-avatar>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
text
rounded
:to="`/streams/${$route.params.streamId}/collaborators`"
:disabled="stream.role !== 'stream:owner'"
>
Manage
</v-btn>
</v-toolbar>
</v-sheet>
<v-sheet
v-if="stream"
:xxxclass="`${!$vuetify.theme.dark ? 'grey lighten-4' : 'grey darken-4'}`"
>
<v-toolbar
v-if="!stream.isPublic"
v-tooltip="
`${
stream.role !== 'stream:owner'
? 'You do not have the right access level (' +
stream.role +
') to invite people to this stream.'
: ''
}`
"
flat
class="transparent"
>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-email</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Missing someone?</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
text
rounded
:disabled="stream.role !== 'stream:owner'"
@click="showStreamInviteDialog()"
>
Send Invite
</v-btn>
</v-toolbar>
</v-sheet>
</v-card>
</v-dialog>
<stream-invite-dialog
v-if="stream"
ref="streamInviteDialog"
:stream-id="$route.params.streamId"
:stream-name="stream.name"
/>
<v-snackbar
v-model="snackbar"
rounded="pill"
:timeout="10000"
style="z-index: 10000"
:color="`${$vuetify.theme.dark ? 'primary' : 'primary'}`"
>
<template v-if="snackbarInfo.type === 'commit'">
<span>New commit created!</span>
</template>
<template v-if="snackbarInfo.type === 'branch'">
<span>Branch "{{ snackbarInfo.name }}" created!</span>
</template>
<template #action="{ attrs }">
<v-btn color="white" text v-bind="attrs" @click="goToItemAndCloseSnackbar()">View</v-btn>
<v-btn color="pink" icon v-bind="attrs" @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'Stream',
components: {
UserAvatar: () => import('@/components/UserAvatar'),
ErrorPlaceholder: () => import('@/components/ErrorPlaceholder'),
BranchNewDialog: () => import('@/components/dialogs/BranchNewDialog'),
StreamInviteDialog: () => import('@/components/dialogs/StreamInviteDialog')
},
data() {
return {
streamNav: true,
error: '',
snackbar: false,
snackbarInfo: {},
editStreamDialog: false,
shareStream: false,
branchMenuOpen: false,
swapPermsLoading: false
}
},
apollo: {
stream: {
query: gql`
query Stream($id: String!) {
stream(id: $id) {
id
name
role
createdAt
updatedAt
description
isPublic
commits {
totalCount
}
collaborators {
id
name
role
company
avatar
}
}
}
`,
variables() {
return {
id: this.$route.params.streamId
}
},
error(err) {
if (err.message) this.error = err.message.replace('GraphQL error: ', '')
else this.error = err
}
},
branchQuery: {
query: gql`
query Stream($id: String!) {
branchQuery: stream(id: $id) {
id
branches {
totalCount
items {
name
description
author {
id
name
}
commits {
totalCount
}
}
}
}
}
`,
variables() {
return {
id: this.$route.params.streamId
}
},
update: (data) => {
// console.log(data.branchQuery.branches.items)
return data.branchQuery
}
},
$subscribe: {
branchCreated: {
query: gql`
subscription($streamId: String!) {
branchCreated(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.$route.params.streamId
}
},
result(args) {
if (!args.data.branchCreated) return
this.snackbar = true
this.snackbarInfo = { ...args.data.branchCreated, type: 'branch' }
},
skip() {
return !this.loggedIn
}
},
commitCreated: {
query: gql`
subscription($streamId: String!) {
commitCreated(streamId: $streamId)
}
`,
variables() {
return {
streamId: this.$route.params.streamId
}
},
result(commitInfo) {
if (!commitInfo.data.commitCreated) return
console.log(commitInfo)
this.snackbar = true
this.snackbarInfo = { ...commitInfo.data.commitCreated, type: 'commit' }
},
skip() {
return !this.loggedIn
}
}
}
},
computed: {
groupedBranches() {
if (!this.branchQuery) return
let branches = this.branchQuery.branches.items
let items = []
for (let b of branches) {
if (b.name === 'globals') continue
let parts = b.name.split('/')
if (parts.length === 1) {
items.push({ ...b, displayName: b.name, type: 'item', children: [] })
} else {
let existing = items.find((i) => i.name === parts[0] && i.type === 'group')
if (!existing) {
existing = { name: parts[0], type: 'group', children: [], expand: false }
items.push(existing)
}
existing.children.push({
...b,
displayName: parts.slice(1).join('/'),
type: 'item'
})
if (this.$route.path.includes(b.name)) existing.expand = true
}
}
let sorted = items.sort((a, b) => {
const nameA = a.name.toLowerCase()
const nameB = b.name.toLowerCase()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
return [
...sorted.filter((it) => it.name === 'main'),
...sorted.filter((it) => it.name !== 'main')
]
// return items
},
streamUrl() {
return `${window.location.origin}/streams/${this.$route.params.streamId}`
},
parsedDescription() {
if (!this.stream || !this.stream.description) return 'No description provided.'
return this.stream.description.replace(
/\[(.+?)\]\((https?:\/\/[a-zA-Z0-9/.(]+?)\)/g,
'<a href="$2" class="text-decoration-none" target="_blank">$1</a>'
)
},
loggedIn() {
return localStorage.getItem('uuid') !== null
},
sortedBranches() {
// TODO: group by `/` (for later)
if (!this.branchQuery) return
return [
this.branchQuery.branches.items.find((b) => b.name === 'main'),
...this.branchQuery.branches.items.filter((b) => b.name !== 'main' && b.name !== 'globals')
]
},
branchesTotalCount() {
if (!this.branchQuery) return 0
return this.branchQuery.branches.items.filter((b) => b.name !== 'globals').length
},
userId() {
return localStorage.getItem('uuid')
},
loggedIn() {
return localStorage.getItem('uuid') !== null
}
},
watch: {
$route(to) {
// Ensures branch menu is open when navigating to a branch url
if (to.name.toLowerCase().includes('branch') && !this.branchMenuOpen)
this.branchMenuOpen = true
// closes any share dialog
this.shareStream = false
this.snackbar = false
}
// branchMenuOpen(val) {
// if (this.$route.name.toLowerCase().includes('branch') && !val)
// this.$nextTick(() => {
// this.branchMenuOpen = true
// })
// }
},
mounted() {
setTimeout(
function () {
this.streamNav = !this.$vuetify.breakpoint.smAndDown
}.bind(this),
1
)
// Ensures branch menu is open when navigating directly to a branch url
this.branchMenuOpen = this.$route.name.toLowerCase().includes('branch')
// Open stream invite dialog if ?invite=true (used by desktop connectors)
if (this.$route.query.invite && this.$route.query.invite === 'true') {
setTimeout(() => {
this.$refs.streamInviteDialog.show()
}, 500)
}
},
methods: {
goToItemAndCloseSnackbar() {
if (this.snackbarInfo.type === 'commit') {
this.$router.push(`/streams/${this.$route.params.streamId}/commits/${this.snackbarInfo.id}`)
} else if (this.snackbarInfo.type === 'branch') {
this.$router.push(
`/streams/${this.$route.params.streamId}/branches/${this.snackbarInfo.name}`
)
this.refetchBranches()
}
this.snackbar = false
},
copyToClipboard(e) {
e.target.select()
document.execCommand('copy')
},
openShareStreamDialog() {
this.shareStream = true
setTimeout(
function () {
// console.log(this.$refs.streamUrl.$refs.input)
this.$refs.streamUrl.$refs.input.select()
document.execCommand('copy')
}.bind(this),
100
)
},
showStreamInviteDialog() {
this.$refs.streamInviteDialog.show()
},
async changeVisibility() {
this.swapPermsLoading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation editDescription($input: StreamUpdateInput!) {
streamUpdate(stream: $input)
}
`,
variables: {
input: {
id: this.$route.params.streamId,
isPublic: this.stream.isPublic
}
}
})
} catch (e) {
console.log(e)
this.stream.isPublic = !this.stream.isPublic
}
this.swapPermsLoading = false
this.$apollo.queries.stream.refetch()
},
refetchBranches() {
this.$apollo.queries.branchQuery.refetch()
},
showNewBranchDialog() {
this.$refs.branchDialog.show()
},
formatDate(d) {
if (!this.stream) return null
let date = new Date(d)
let options = { year: 'numeric', month: 'short', day: 'numeric' }
return date.toLocaleString(undefined, options)
}
}
}
</script>
<style scoped>
.no-overlay.v-list-item--active::before {
opacity: 0 !important;
}
</style>
@@ -1,229 +0,0 @@
<template>
<div>
<v-container style="max-width: 768px">
<portal to="streamTitleBar">
<div>
<v-icon small class="mr-2 hidden-xs-only">mdi-arrow-up</v-icon>
<span class="space-grotesk">Import IFC</span>
</div>
</portal>
<v-card elevation="1" rounded="lg" :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`">
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'white' : ''} mb-2`">
<v-toolbar-title>
<v-icon class="mr-2" small>mdi-arrow-up</v-icon>
<span class="d-inline-block">Import IFC Files - Alpha</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text>
Speckle can now process IFC files and store them as a commit (snapshot). You can then
access it from the Speckle API, and receive it in other applications.
<!-- </v-card-text>
<v-card-text> -->
Thanks to the Open Source
<a href="https://ifcjs.github.io/info/docs/Guide/web-ifc/Introduction" target="_blank">
IFC.js Project
</a>
for making this possible.
</v-card-text>
</v-card>
<v-alert v-if="stream && (stream.role === 'stream:reviewer' || !stream.role)" type="warning">
Your permission level ({{ stream.role ? stream.role : 'none' }}) is not high enough to
access this feature.
</v-alert>
<div v-if="stream && !(stream.role === 'stream:reviewer' || !stream.role)">
<v-card
elevation="0"
color="transparent"
class=""
style="height: 220px; transition: all 0.2s ease"
:class="`mt-4 mb-4 d-flex justify-center
${dragover && !$vuetify.theme.dark ? 'grey lighten-4' : ''}
${dragover && $vuetify.theme.dark ? 'grey darken-4' : ''}
`"
@drop.prevent="onFileDrop($event)"
@dragover.prevent="dragover = true"
@dragenter.prevent="dragover = true"
@dragleave.prevent="dragover = false"
>
<div v-if="!dragError" class="align-self-center text-center">
<input
id="myid"
type="file"
accept=".ifc,.IFC"
style="display: none"
multiple
@change="onFileSelect($event)"
/>
<v-icon
x-large
color="primary"
:class="`hover-tada ${dragover ? 'tada' : ''}`"
style="cursor: pointer"
onclick="document.getElementById('myid').click()"
>
mdi-cloud-upload
</v-icon>
<br />
<span class="primary--text">Drag and drop your IFC file here!</span>
<br />
<span class="caption">Maximum 5 files at a time. Size is restricted to 50mb each.</span>
</div>
<v-alert
v-if="dragError"
dismissible
class="align-self-center text-center"
type="error"
@click="dragError = null"
>
{{ dragError }}
</v-alert>
</v-card>
<!-- {{ uploads }} -->
<template v-for="file in files">
<file-upload-item
:key="file.fileName"
:file="file"
:branches="stream.branches.items"
@done="uploadCompleted"
></file-upload-item>
</template>
</div>
<v-card elevation="1" rounded="lg" :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`">
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'white' : ''} mb-2`">
<v-toolbar-title>
<!-- <v-icon class="mr-2" small>mdi-arrow-up</v-icon> -->
<span class="d-inline-block">Previous Uploads</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text>
Here are the previously uploaded files in this stream. Please note, currently processing
time is restricted to 5 minutes - if a file takes longer to process, it will be ignored.
</v-card-text>
</v-card>
<template v-for="file in streamUploads" v-if="!$apollo.loading">
<file-processing-item :key="file.id" :file-id="file.id" />
</template>
<v-card v-if="!$apollo.loading && streamUploads.length === 0" class="my-4 elevation-1">
<v-toolbar dense flat color="transparent">
<v-toolbar-title>No uploads yet.</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
</v-card>
</v-container>
</div>
</template>
<script>
import gql from 'graphql-tag'
export default {
name: 'Webhooks',
components: {
FileUploadItem: () => import('@/components/FileUploadItem'),
FileProcessingItem: () => import('@/components/FileProcessingItem')
},
apollo: {
stream: {
query: gql`
query stream($id: String!) {
stream(id: $id) {
id
role
branches {
totalCount
items {
name
}
}
}
}
`,
variables() {
return { id: this.$route.params.streamId }
}
},
streamUploads: {
query: gql`
query streamUploads($streamId: String!) {
stream(id: $streamId) {
id
role
fileUploads {
id
}
}
}
`,
update: (data) => data.stream.fileUploads,
variables() {
return {
streamId: this.$route.params.streamId
}
}
}
},
data() {
return {
dragover: false,
loading: false,
stream: null,
files: [],
showUploadDialog: false,
error: null,
dragError: null
}
},
computed: {},
methods: {
onFileSelect(e) {
this.parseFiles(e.target.files)
},
onFileDrop(e) {
this.parseFiles(e.dataTransfer.files)
},
parseFiles(files) {
this.dragover = false
this.dragError = null
for (const file of files) {
console.log(file.name.split('.')[1])
let extension = file.name.split('.')[1]
if (!extension || extension !== 'ifc') {
this.dragError = 'Only IFC file extensions are supported.'
return
}
if (file.size > 50626997) {
this.dragError = 'Your files are too powerful (for now). Maximum upload size is 50mb!'
return
}
if (this.files.findIndex((f) => f.name === file.name) !== -1) {
this.dragError = 'This file is already primed for upload.'
return
}
}
if (files.length > 5) {
this.dragError = 'Maximum five files at a time allowed.'
return
}
this.dragError = null
for (const file of files) {
this.files.push(file)
}
},
uploadCompleted(file) {
const index = this.files.findIndex((f) => f.name === file)
this.files.splice(index, 1)
this.$apollo.queries.streamUploads.refetch()
}
}
}
</script>
@@ -1,306 +0,0 @@
<template>
<div>
<no-data-placeholder
v-if="!$apollo.loading && webhooks.length === 0 && stream && stream.role === 'stream:owner'"
:show-message="false"
>
<h2>This stream has no webhooks.</h2>
<p class="caption">
Webhooks allow you to subscribe to a stream's events and get notified of them in real time.
You can then use this to trigger ci apps, automation workflows, and more.
</p>
<template #actions>
<v-list rounded class="transparent">
<v-list-item link class="primary mb-4" dark @click="newWebhookDialog = true">
<v-list-item-icon>
<v-icon>mdi-plus-box</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Create Webhook</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
link
:class="`grey ${$vuetify.theme.dark ? 'darken-4' : 'lighten-4'} mb-4`"
href="https://speckle.guide/dev/server-webhooks.html"
target="_blank"
>
<v-list-item-icon>
<v-icon>mdi-book-open-variant</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Webhook docs</v-list-item-title>
<v-list-item-subtitle class="caption">
Read the documentation on webhooks.
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
</no-data-placeholder>
<error-placeholder v-if="error" error-type="access">
<h2>Only stream owners can access webhooks.</h2>
<p class="caption">
If you need to use webhooks, ask the stream's owner to grant you ownership.
</p>
</error-placeholder>
<v-container v-if="!$apollo.loading && webhooks.length !== 0" style="max-width: 768px">
<portal to="streamTitleBar">
<div>
<v-icon small class="mr-2 hidden-xs-only">mdi-webhook</v-icon>
<span class="space-grotesk">Webhooks</span>
</div>
</portal>
<v-card elevation="0" rounded="lg" :class="`${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`">
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''}`">
<v-toolbar-title>
<v-icon class="mr-2" small>mdi-webhook</v-icon>
<span class="d-inline-block">What are Webhooks?</span>
</v-toolbar-title>
</v-toolbar>
<v-card-text class="pb-1">
<p class="caption">
Webhooks allow you to subscribe to a stream's events and get notified of them in real
time. You can then use this to trigger ci apps, automation workflows, and more. Read
more on webhooks
<a href="https://speckle.guide/dev/server-webhooks.html" target="_blank">here</a>
.
</p>
</v-card-text>
</v-card>
<v-card
elevation="0"
rounded="lg"
:loading="loading"
:class="`mt-2 ${!$vuetify.theme.dark ? 'grey lighten-5' : ''}`"
>
<v-toolbar flat :class="`${!$vuetify.theme.dark ? 'grey lighten-4' : ''}`">
<v-toolbar-title>
<v-icon class="mr-2" small>mdi-webhook</v-icon>
<span class="d-inline-block">Existing Webhooks</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn small class="primary" dark @click="newWebhookDialog = true">New Webhook</v-btn>
</v-toolbar>
<v-list subheader class="transparent pa-0 ma-0">
<v-list-item v-for="wh in webhooks" :key="wh.id" link style="cursor: default">
<v-list-item-icon>
<v-icon :color="wh.statusIcon.color" class="pt-2">
{{ wh.statusIcon.icon }}
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ wh.description ? wh.description : `Webhook ${wh.id}` }}
</v-list-item-title>
<v-list-item-subtitle class="caption">
{{ wh.url }} {{ `(${wh.triggers.join(', ')})` }}
</v-list-item-subtitle>
<v-list-item-subtitle class="caption">
{{ getStatusInfo(wh) }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action v-if="wh.history.items.length != 0">
<v-btn
v-tooltip="'View status reports'"
icon
@click="
selectedWebhook = wh
statusReportsDialog = true
"
>
<v-icon>mdi-information</v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-action>
<v-btn
small
@click="
selectedWebhook = wh
editWebhookDialog = true
"
>
edit
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card>
<v-card-text v-if="webhooks && webhooks.length == 0">
There are no webhooks on this stream yet.
<v-btn
text
small
color="primary"
href="https://speckle.guide/dev/server-webhooks.html"
target="_blank"
>
Read the docs
</v-btn>
</v-card-text>
</v-container>
<v-dialog v-model="newWebhookDialog" width="500" :fullscreen="$vuetify.breakpoint.smAndDown">
<v-card>
<v-toolbar>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-plus-box</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Create Webhook</v-toolbar-title>
<v-spacer />
<v-toolbar-items>
<v-btn icon @click="newWebhookDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar-items>
</v-toolbar>
<webhook-form
:loading.sync="loading"
:stream-id="$attrs.streamId"
@refetch-webhooks="refetchWebhooks"
@close="newWebhookDialog = false"
/>
</v-card>
</v-dialog>
<v-dialog v-model="editWebhookDialog" width="500" :fullscreen="$vuetify.breakpoint.smAndDown">
<v-card>
<v-toolbar>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-pencil</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Edit Webhook</v-toolbar-title>
<v-spacer />
<v-toolbar-items>
<v-btn icon @click="editWebhookDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar-items>
</v-toolbar>
<webhook-form
v-if="selectedWebhook"
:loading.sync="loading"
:stream-id="$attrs.streamId"
:webhook-id="selectedWebhook.id"
@refetch-webhooks="refetchWebhooks"
@close="editWebhookDialog = false"
/>
</v-card>
</v-dialog>
<v-dialog v-model="statusReportsDialog" width="500" :fullscreen="$vuetify.breakpoint.smAndDown">
<v-card>
<v-toolbar>
<v-app-bar-nav-icon style="pointer-events: none">
<v-icon>mdi-information</v-icon>
</v-app-bar-nav-icon>
<v-toolbar-title>Webhook Status Reports</v-toolbar-title>
<v-spacer />
<v-toolbar-items>
<v-btn icon @click="statusReportsDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-list v-if="selectedWebhook">
<v-subheader>Latest delivery reports:</v-subheader>
<v-list-item v-for="(sr, index) in selectedWebhook.history.items" :key="index">
<v-list-item-icon>
<v-icon :class="`${sr.status === 2 ? 'green--text' : 'red--text'}`">
{{ sr.status === 2 ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<div>
{{ sr.statusInfo }}
</div>
<v-list-item-subtitle class="caption">
Last update: {{ sr.lastUpdate }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</v-dialog>
</div>
</template>
<script>
import webhooksQuery from '@/graphql/webhooks.gql'
export default {
name: 'Webhooks',
components: {
WebhookForm: () => import('@/components/settings/WebhookForm'),
NoDataPlaceholder: () => import('@/components/NoDataPlaceholder'),
ErrorPlaceholder: () => import('@/components/ErrorPlaceholder')
},
apollo: {
stream: {
query: webhooksQuery,
variables() {
return {
streamId: this.$route.params.streamId
}
},
update(data) {
data.stream.webhooks.items.forEach((wh) => {
wh.statusIcon = this.getStatusIcon(wh)
})
return data.stream
},
error(err) {
if (err.message) this.error = err.message.replace('GraphQL error: ', '')
else this.error = err
}
}
},
data() {
return {
loading: false,
stream: null,
newWebhookDialog: false,
editWebhookDialog: false,
statusReportsDialog: false,
selectedWebhook: null,
error: null
}
},
computed: {
webhooks() {
if (this.stream) return this.stream.webhooks.items
return []
}
},
methods: {
getStatusIcon(webhook) {
let status = 5 // default 5 if no events
if (webhook.history.items.length) status = webhook.history.items[0].status
switch (status) {
case 0:
case 1:
return { color: 'amber', icon: 'mdi-alert-outline' }
case 2:
return { color: 'green', icon: 'mdi-check' }
case 3:
return { color: 'red', icon: 'mdi-close' }
default:
return { color: 'blue-grey', icon: 'mdi-alert-circle-outline' }
}
},
getStatusInfo(webhook) {
if (!webhook.history.items.length) return 'No events yet'
let msg = webhook.history.items[0].statusInfo
return msg
},
refetchWebhooks() {
this.$apollo.queries.stream.refetch()
}
}
}
</script>