Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
wiki-js
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Jacklull
wiki-js
Commits
4675348c
Unverified
Commit
4675348c
authored
Jun 19, 2022
by
Nicolas Giard
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(admin): migrate nav + auth to vue 3 composition, convert lodash to lodash-es
parent
8fb29cfc
Hide whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
573 additions
and
543 deletions
+573
-543
package.json
ux/package.json
+1
-0
AdminLayout.vue
ux/src/layouts/AdminLayout.vue
+1
-1
AdminApi.vue
ux/src/pages/AdminApi.vue
+1
-1
AdminAuth.vue
ux/src/pages/AdminAuth.vue
+279
-260
AdminExtensions.vue
ux/src/pages/AdminExtensions.vue
+1
-1
AdminFlags.vue
ux/src/pages/AdminFlags.vue
+1
-1
AdminGeneral.vue
ux/src/pages/AdminGeneral.vue
+1
-1
AdminGroups.vue
ux/src/pages/AdminGroups.vue
+1
-1
AdminLocale.vue
ux/src/pages/AdminLocale.vue
+3
-5
AdminLogin.vue
ux/src/pages/AdminLogin.vue
+1
-2
AdminMail.vue
ux/src/pages/AdminMail.vue
+4
-6
AdminNavigation.vue
ux/src/pages/AdminNavigation.vue
+270
-251
AdminSecurity.vue
ux/src/pages/AdminSecurity.vue
+2
-3
AdminStorage.vue
ux/src/pages/AdminStorage.vue
+1
-3
AdminSystem.vue
ux/src/pages/AdminSystem.vue
+1
-1
AdminTheme.vue
ux/src/pages/AdminTheme.vue
+1
-2
AdminUsers.vue
ux/src/pages/AdminUsers.vue
+1
-1
AdminWebhooks.vue
ux/src/pages/AdminWebhooks.vue
+1
-1
routes.js
ux/src/router/routes.js
+2
-2
yarn.lock
ux/yarn.lock
+0
-0
No files found.
ux/package.json
View file @
4675348c
...
...
@@ -69,6 +69,7 @@
"js-cookie"
:
"3.0.1"
,
"jwt-decode"
:
"3.1.2"
,
"lodash"
:
"4.17.21"
,
"lodash-es"
:
"4.17.21"
,
"luxon"
:
"2.4.0"
,
"pinia"
:
"2.0.14"
,
"pug"
:
"3.0.2"
,
...
...
ux/src/layouts/AdminLayout.vue
View file @
4675348c
...
...
@@ -95,7 +95,7 @@ q-layout.admin(view='hHh Lpr lff')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
q-item-section
{{
t
(
'admin.navigation.title'
)
}}
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/rendering`', v-ripple, active-class='bg-primary text-white')
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/rendering`', v-ripple, active-class='bg-primary text-white'
, disabled
)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
q-item-section
{{
t
(
'admin.rendering.title'
)
}}
...
...
ux/src/pages/AdminApi.vue
View file @
4675348c
...
...
@@ -116,7 +116,7 @@ q-page.admin-api
<
script
setup
>
import
gql
from
'graphql-tag'
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
computed
,
onMounted
,
reactive
,
watch
}
from
'vue'
...
...
ux/src/pages/AdminAuth.vue
View file @
4675348c
...
...
@@ -4,8 +4,8 @@ q-page.admin-mail
.col-auto
img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-security-lock.svg')
.col.q-pl-md
.text-h5.text-primary.animated.fadeInLeft
{{
$
t
(
'admin.auth.title'
)
}}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s
{{
$
t
(
'admin.auth.subtitle'
)
}}
.text-h5.text-primary.animated.fadeInLeft
{{
t
(
'admin.auth.title'
)
}}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s
{{
t
(
'admin.auth.subtitle'
)
}}
.col-auto
q-btn.q-mr-sm.acrylic-btn(
icon='las la-question-circle'
...
...
@@ -17,11 +17,11 @@ q-page.admin-mail
)
q-btn(
unelevated
icon='
mdi
-check'
:label='
$
t(`common.actions.apply`)'
icon='
fa-solid fa
-check'
:label='t(`common.actions.apply`)'
color='secondary'
@click='save'
:loading='
loading
'
:loading='
state.loading > 0
'
)
q-separator(inset)
.row.q-pa-md.q-col-gutter-md
...
...
@@ -33,11 +33,11 @@ q-page.admin-mail
dark
)
q-item(
v-for='str of activeStrategies'
v-for='str of
state.
activeStrategies'
:key='str.key'
active-class='bg-primary text-white'
:active='selectedStrategy === str.key'
@click='selectedStrategy = str.key'
:active='s
tate.s
electedStrategy === str.key'
@click='s
tate.s
electedStrategy = str.key'
clickable
)
q-item-section(side)
...
...
@@ -52,7 +52,7 @@ q-page.admin-mail
q-btn.q-mt-sm.full-width(
color='primary'
icon='las la-plus'
:label='
$
t(`admin.auth.addStrategy`)'
:label='t(`admin.auth.addStrategy`)'
)
q-menu(auto-close)
q-list(style='min-width: 350px;')
...
...
@@ -261,275 +261,294 @@ q-page.admin-mail
//- .body-2 HTTP-POST
</
template
>
<
script
>
import
_
from
'lodash'
<
script
setup
>
import
gql
from
'graphql-tag'
import
{
find
,
reject
}
from
'lodash-es'
import
{
v4
as
uuid
}
from
'uuid'
import
{
createMetaMixin
}
from
'quasar'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
computed
,
onMounted
,
reactive
,
watch
,
nextTick
}
from
'vue'
import
{
useAdminStore
}
from
'src/stores/admin'
import
{
useSiteStore
}
from
'src/stores/site'
import
draggable
from
'vuedraggable'
export
default
{
mixins
:
[
createMetaMixin
(
function
()
{
return
{
title
:
this
.
$t
(
'admin.auth.title'
)
}
})
],
components
:
{
draggable
},
filters
:
{
startCase
(
val
)
{
return
_
.
startCase
(
val
)
}
},
data
()
{
return
{
groups
:
[],
strategies
:
[],
activeStrategies
:
[
{
key
:
'local'
,
strategy
:
{
key
:
'local'
,
title
:
'Username-Password Authentication'
,
description
:
''
,
useForm
:
true
,
icon
:
'/_assets/icons/ultraviolet-data-protection.svg'
,
website
:
''
},
config
:
[],
isEnabled
:
true
,
displayName
:
'Local Database'
,
selfRegistration
:
false
,
domainWhitelist
:
''
,
autoEnrollGroups
:
[]
},
{
key
:
'google'
,
strategy
:
{
key
:
'google'
,
title
:
'Google'
,
description
:
''
,
useForm
:
true
,
icon
:
'/_assets/icons/ultraviolet-google.svg'
,
website
:
''
},
config
:
[],
isEnabled
:
true
,
displayName
:
'Google'
,
selfRegistration
:
false
,
domainWhitelist
:
''
,
autoEnrollGroups
:
[]
},
{
key
:
'slack'
,
strategy
:
{
key
:
'slack'
,
title
:
'Slack'
,
description
:
''
,
useForm
:
true
,
icon
:
'/_assets/icons/ultraviolet-slack.svg'
,
website
:
''
},
config
:
[],
isEnabled
:
false
,
displayName
:
'Slack'
,
selfRegistration
:
false
,
domainWhitelist
:
''
,
autoEnrollGroups
:
[]
}
],
selectedStrategy
:
''
,
host
:
''
,
// QUASAR
const
$q
=
useQuasar
()
// STORES
const
adminStore
=
useAdminStore
()
const
siteStore
=
useSiteStore
()
// I18N
const
{
t
}
=
useI18n
()
// META
useMeta
({
title
:
t
(
'admin.auth.title'
)
})
// DATA
const
state
=
reactive
({
loading
:
0
,
groups
:
[],
strategies
:
[],
activeStrategies
:
[
{
key
:
'local'
,
strategy
:
{
strategy
:
{}
}
}
},
watch
:
{
selectedStrategy
(
newValue
,
oldValue
)
{
this
.
strategy
=
_
.
find
(
this
.
activeStrategies
,
[
'key'
,
newValue
])
||
{}
},
activeStrategies
(
newValue
,
oldValue
)
{
this
.
selectedStrategy
=
'local'
}
},
methods
:
{
async
refresh
()
{
await
this
.
$apollo
.
queries
.
strategies
.
refetch
()
await
this
.
$apollo
.
queries
.
activeStrategies
.
refetch
()
this
.
$store
.
commit
(
'showNotification'
,
{
message
:
this
.
$t
(
'admin.auth.refreshSuccess'
),
style
:
'success'
,
icon
:
'cached'
})
key
:
'local'
,
title
:
'Username-Password Authentication'
,
description
:
''
,
useForm
:
true
,
icon
:
'/_assets/icons/ultraviolet-data-protection.svg'
,
website
:
''
},
config
:
[],
isEnabled
:
true
,
displayName
:
'Local Database'
,
selfRegistration
:
false
,
domainWhitelist
:
''
,
autoEnrollGroups
:
[]
},
addStrategy
(
str
)
{
const
newStr
=
{
key
:
uuid
(),
strategy
:
str
,
config
:
str
.
props
.
map
(
c
=>
({
key
:
c
.
key
,
value
:
{
...
c
,
value
:
c
.
default
}
})),
order
:
this
.
activeStrategies
.
length
,
isEnabled
:
true
,
displayName
:
str
.
title
,
selfRegistration
:
false
,
domainWhitelist
:
[],
autoEnrollGroups
:
[]
}
this
.
activeStrategies
=
[...
this
.
activeStrategies
,
newStr
]
this
.
$nextTick
(()
=>
{
this
.
selectedStrategy
=
newStr
.
key
})
},
deleteStrategy
()
{
this
.
activeStrategies
=
_
.
reject
(
this
.
activeStrategies
,
[
'key'
,
this
.
strategy
.
key
])
{
key
:
'google'
,
strategy
:
{
key
:
'google'
,
title
:
'Google'
,
description
:
''
,
useForm
:
true
,
icon
:
'/_assets/icons/ultraviolet-google.svg'
,
website
:
''
},
config
:
[],
isEnabled
:
true
,
displayName
:
'Google'
,
selfRegistration
:
false
,
domainWhitelist
:
''
,
autoEnrollGroups
:
[]
},
async
save
()
{
this
.
$store
.
commit
(
'loadingStart'
,
'admin-auth-savestrategies'
)
try
{
const
resp
=
await
this
.
$apollo
.
mutate
({
mutation
:
gql
`
mutation($strategies: [AuthenticationStrategyInput]!) {
authentication {
updateStrategies(strategies: $strategies) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
`
,
variables
:
{
strategies
:
this
.
activeStrategies
.
map
((
str
,
idx
)
=>
({
key
:
str
.
key
,
strategyKey
:
str
.
strategy
.
key
,
displayName
:
str
.
displayName
,
order
:
idx
,
isEnabled
:
str
.
isEnabled
,
config
:
str
.
config
.
map
(
cfg
=>
({
...
cfg
,
value
:
JSON
.
stringify
({
v
:
cfg
.
value
.
value
})
})),
selfRegistration
:
str
.
selfRegistration
,
domainWhitelist
:
str
.
domainWhitelist
,
autoEnrollGroups
:
str
.
autoEnrollGroups
}))
}
})
if
(
_
.
get
(
resp
,
'data.authentication.updateStrategies.responseResult.succeeded'
,
false
))
{
this
.
$store
.
commit
(
'showNotification'
,
{
message
:
this
.
$t
(
'admin.auth.saveSuccess'
),
style
:
'success'
,
icon
:
'check'
})
}
else
{
throw
new
Error
(
_
.
get
(
resp
,
'data.authentication.updateStrategies.responseResult.message'
,
this
.
$t
(
'common.error.unexpected'
)))
}
}
catch
(
err
)
{
this
.
$store
.
commit
(
'pushGraphError'
,
err
)
}
this
.
$store
.
commit
(
'loadingStop'
,
'admin-auth-savestrategies'
)
{
key
:
'slack'
,
strategy
:
{
key
:
'slack'
,
title
:
'Slack'
,
description
:
''
,
useForm
:
true
,
icon
:
'/_assets/icons/ultraviolet-slack.svg'
,
website
:
''
},
config
:
[],
isEnabled
:
false
,
displayName
:
'Slack'
,
selfRegistration
:
false
,
domainWhitelist
:
''
,
autoEnrollGroups
:
[]
}
},
apollo
:
{
strategies
:
{
query
:
gql
`
query {
authentication {
strategies {
key
title
description
isAvailable
useForm
logo
website
props {
key
value
}
}
}
}
`
,
skip
:
true
,
fetchPolicy
:
'network-only'
,
update
:
(
data
)
=>
_
.
get
(
data
,
'authentication.strategies'
,
[]).
map
(
str
=>
({
...
str
,
isDisabled
:
!
str
.
isAvailable
||
str
.
key
===
'local'
,
props
:
_
.
sortBy
(
str
.
props
.
map
(
cfg
=>
({
key
:
cfg
.
key
,
...
JSON
.
parse
(
cfg
.
value
)
})),
[
t
=>
t
.
order
])
})),
watchLoading
(
isLoading
)
{
this
.
$store
.
commit
(
`loading
${
isLoading
?
'Start'
:
'Stop'
}
`
,
'admin-auth-strategies-refresh'
)
],
selectedStrategy
:
''
,
host
:
''
,
strategy
:
{
strategy
:
{}
}
})
// WATCHERS
watch
(()
=>
state
.
selectedStrategy
,
(
newValue
,
oldValue
)
=>
{
state
.
strategy
=
find
(
state
.
activeStrategies
,
[
'key'
,
newValue
])
||
{}
})
watch
(()
=>
state
.
activeStrategies
,
(
newValue
,
oldValue
)
=>
{
state
.
selectedStrategy
=
'local'
})
// METHODS
async
function
refresh
()
{
await
this
.
$apollo
.
queries
.
strategies
.
refetch
()
await
this
.
$apollo
.
queries
.
activeStrategies
.
refetch
()
this
.
$store
.
commit
(
'showNotification'
,
{
message
:
this
.
$t
(
'admin.auth.refreshSuccess'
),
style
:
'success'
,
icon
:
'cached'
})
}
function
addStrategy
(
str
)
{
const
newStr
=
{
key
:
uuid
(),
strategy
:
str
,
config
:
str
.
props
.
map
(
c
=>
({
key
:
c
.
key
,
value
:
{
...
c
,
value
:
c
.
default
}
},
activeStrategies
:
{
query
:
gql
`
query {
})),
order
:
state
.
activeStrategies
.
length
,
isEnabled
:
true
,
displayName
:
str
.
title
,
selfRegistration
:
false
,
domainWhitelist
:
[],
autoEnrollGroups
:
[]
}
state
.
activeStrategies
=
[...
state
.
activeStrategies
,
newStr
]
nextTick
(()
=>
{
state
.
selectedStrategy
=
newStr
.
key
})
}
function
deleteStrategy
()
{
state
.
activeStrategies
=
reject
(
state
.
activeStrategies
,
[
'key'
,
state
.
strategy
.
key
])
}
async
function
save
()
{
state
.
loading
++
try
{
const
resp
=
await
APOLLO_CLIENT
.
mutate
({
mutation
:
gql
`
mutation($strategies: [AuthenticationStrategyInput]!) {
authentication {
activeStrategies {
key
strategy {
key
title
description
useForm
logo
website
updateStrategies(strategies: $strategies) {
responseResult {
succeeded
errorCode
slug
message
}
config {
key
value
}
order
isEnabled
displayName
selfRegistration
domainWhitelist
autoEnrollGroups
}
}
}
`
,
skip
:
true
,
fetchPolicy
:
'network-only'
,
update
:
(
data
)
=>
_
.
sortBy
(
_
.
get
(
data
,
'authentication.activeStrategies'
,
[]).
map
(
str
=>
({
...
str
,
config
:
_
.
sortBy
(
str
.
config
.
map
(
cfg
=>
({
...
cfg
,
value
:
JSON
.
parse
(
cfg
.
value
)
})),
[
t
=>
t
.
value
.
order
])
})),
[
'order'
]),
watchLoading
(
isLoading
)
{
this
.
$store
.
commit
(
`loading
${
isLoading
?
'Start'
:
'Stop'
}
`
,
'admin-auth-activestrategies-refresh'
)
}
},
groups
:
{
query
:
gql
`{ test }`
,
fetchPolicy
:
'network-only'
,
update
:
(
data
)
=>
data
.
groups
.
list
,
watchLoading
(
isLoading
)
{
this
.
$store
.
commit
(
`loading
${
isLoading
?
'Start'
:
'Stop'
}
`
,
'admin-auth-groups-refresh'
)
}
},
host
:
{
query
:
gql
`{ test }`
,
fetchPolicy
:
'network-only'
,
update
:
(
data
)
=>
_
.
cloneDeep
(
data
.
site
.
config
.
host
),
watchLoading
(
isLoading
)
{
this
.
$store
.
commit
(
`loading
${
isLoading
?
'Start'
:
'Stop'
}
`
,
'admin-auth-host-refresh'
)
variables
:
{
strategies
:
this
.
activeStrategies
.
map
((
str
,
idx
)
=>
({
key
:
str
.
key
,
strategyKey
:
str
.
strategy
.
key
,
displayName
:
str
.
displayName
,
order
:
idx
,
isEnabled
:
str
.
isEnabled
,
config
:
str
.
config
.
map
(
cfg
=>
({
...
cfg
,
value
:
JSON
.
stringify
({
v
:
cfg
.
value
.
value
})
})),
selfRegistration
:
str
.
selfRegistration
,
domainWhitelist
:
str
.
domainWhitelist
,
autoEnrollGroups
:
str
.
autoEnrollGroups
}))
}
})
if
(
resp
?.
data
?.
authentication
?.
updateStrategies
?.
operation
.
succeeded
)
{
$q
.
notify
({
type
:
'positive'
,
message
:
t
(
'admin.auth.saveSuccess'
)
})
}
else
{
throw
new
Error
(
resp
?.
data
?.
authentication
?.
updateStrategies
?.
operation
?.
message
||
t
(
'common.error.unexpected'
))
}
}
catch
(
err
)
{
$q
.
notify
({
type
:
'negative'
,
message
:
'Failed to save site theme config'
,
caption
:
err
.
message
})
}
state
.
loading
--
}
// apollo: {
// strategies: {
// query: gql`
// query {
// authentication {
// strategies {
// key
// title
// description
// isAvailable
// useForm
// logo
// website
// props {
// key
// value
// }
// }
// }
// }
// `,
// skip: true,
// fetchPolicy: 'network-only',
// update: (data) => _.get(data, 'authentication.strategies', []).map(str => ({
// ...str,
// isDisabled: !str.isAvailable || str.key === 'local',
// props: _.sortBy(str.props.map(cfg => ({
// key: cfg.key,
// ...JSON.parse(cfg.value)
// })), [t => t.order])
// })),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-strategies-refresh')
// }
// },
// activeStrategies: {
// query: gql`
// query {
// authentication {
// activeStrategies {
// key
// strategy {
// key
// title
// description
// useForm
// logo
// website
// }
// config {
// key
// value
// }
// order
// isEnabled
// displayName
// selfRegistration
// domainWhitelist
// autoEnrollGroups
// }
// }
// }
// `,
// skip: true,
// fetchPolicy: 'network-only',
// update: (data) => _.sortBy(_.get(data, 'authentication.activeStrategies', []).map(str => ({
// ...str,
// config: _.sortBy(str.config.map(cfg => ({
// ...cfg,
// value: JSON.parse(cfg.value)
// })), [t => t.value.order])
// })), ['order']),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-activestrategies-refresh')
// }
// },
// groups: {
// query: gql`{ test }`,
// fetchPolicy: 'network-only',
// update: (data) => data.groups.list,
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-groups-refresh')
// }
// },
// host: {
// query: gql`{ test }`,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.site.config.host),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-host-refresh')
// }
// }
</
script
>
ux/src/pages/AdminExtensions.vue
View file @
4675348c
...
...
@@ -99,7 +99,7 @@ q-page.admin-extensions
<
script
setup
>
import
gql
from
'graphql-tag'
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
computed
,
onMounted
,
reactive
,
watch
}
from
'vue'
...
...
ux/src/pages/AdminFlags.vue
View file @
4675348c
...
...
@@ -82,7 +82,7 @@ q-page.admin-flags
<
script
setup
>
import
gql
from
'graphql-tag'
import
{
defineAsyncComponent
,
onMounted
,
reactive
,
ref
,
watch
}
from
'vue'
import
_transform
from
'lodash/transform
'
import
{
transform
}
from
'lodash-es
'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
useI18n
}
from
'vue-i18n'
...
...
ux/src/pages/AdminGeneral.vue
View file @
4675348c
...
...
@@ -391,7 +391,7 @@ q-page.admin-general
<
script
setup
>
import
gql
from
'graphql-tag'
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
onMounted
,
reactive
,
watch
}
from
'vue'
...
...
ux/src/pages/AdminGroups.vue
View file @
4675348c
...
...
@@ -93,7 +93,7 @@ q-page.admin-groups
<
script
setup
>
import
gql
from
'graphql-tag'
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
computed
,
onBeforeUnmount
,
onMounted
,
reactive
,
watch
}
from
'vue'
...
...
ux/src/pages/AdminLocale.vue
View file @
4675348c
...
...
@@ -143,9 +143,7 @@ q-page.admin-locale
<
script
setup
>
import
gql
from
'graphql-tag'
import
filter
from
'lodash/filter'
import
_get
from
'lodash/get'
import
cloneDeep
from
'lodash/cloneDeep'
import
{
cloneDeep
,
filter
}
from
'lodash-es'
import
LocaleInstallDialog
from
'../components/LocaleInstallDialog.vue'
...
...
@@ -278,7 +276,7 @@ async function download (lc) {
locale
:
lc
.
code
}
}
)
const
resp
=
_get
(
respRaw
,
'data.localization.downloadLocale.responseResult'
,
{
}
)
const
resp
=
respRaw
?.
data
?.
localization
?.
downloadLocale
?.
responseResult
||
{
}
if
(
resp
.
succeeded
)
{
lc
.
isDownloading
=
false
lc
.
isInstalled
=
true
...
...
@@ -331,7 +329,7 @@ async function save () {
namespaces
:
state
.
namespaces
}
}
)
const
resp
=
_get
(
respRaw
,
'data.localization.updateLocale.responseResult'
,
{
}
)
const
resp
=
respRaw
?.
data
?.
localization
?.
updateLocale
?.
responseResult
||
{
}
if
(
resp
.
succeeded
)
{
// Change UI language
this
.
$i18n
.
locale
=
state
.
selectedLocale
...
...
ux/src/pages/AdminLogin.vue
View file @
4675348c
...
...
@@ -176,8 +176,7 @@ q-page.admin-login
</
template
>
<
script
setup
>
import
{
get
}
from
'vuex-pathify'
import
cloneDeep
from
'lodash/cloneDeep'
import
{
cloneDeep
}
from
'lodash-es'
import
gql
from
'graphql-tag'
import
draggable
from
'vuedraggable'
...
...
ux/src/pages/AdminMail.vue
View file @
4675348c
...
...
@@ -298,9 +298,7 @@ q-page.admin-mail
</
template
>
<
script
setup
>
import
toSafeInteger
from
'lodash/toSafeInteger'
import
_get
from
'lodash/get'
import
cloneDeep
from
'lodash/cloneDeep'
import
{
cloneDeep
,
toSafeInteger
}
from
'lodash-es'
import
gql
from
'graphql-tag'
import
{
useI18n
}
from
'vue-i18n'
...
...
@@ -477,7 +475,7 @@ async function sendTest () {
sendMailTest(
recipientEmail: $recipientEmail
) {
status
{
operation
{
succeeded
slug
message
...
...
@@ -489,8 +487,8 @@ async function sendTest () {
recipientEmail
:
state
.
testEmail
}
})
if
(
!
_get
(
resp
,
'data.sendMailTest.status.succeeded'
,
false
)
)
{
throw
new
Error
(
_get
(
resp
,
'data.sendMailTest.status.message'
,
'An unexpected error occurred.'
)
)
if
(
!
resp
?.
data
?.
sendMailTest
?.
operation
?.
succeeded
)
{
throw
new
Error
(
resp
?.
data
?.
sendMailTest
?.
operation
?.
message
||
'An unexpected error occurred.'
)
}
state
.
testEmail
=
''
...
...
ux/src/pages/AdminNavigation.vue
View file @
4675348c
...
...
@@ -4,8 +4,8 @@ q-page.admin-navigation
.col-auto
img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-tree-structure.svg')
.col.q-pl-md
.text-h5.text-primary.animated.fadeInLeft
{{
$
t
(
'admin.navigation.title'
)
}}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s
{{
$
t
(
'admin.navigation.subtitle'
)
}}
.text-h5.text-primary.animated.fadeInLeft
{{
t
(
'admin.navigation.title'
)
}}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s
{{
t
(
'admin.navigation.subtitle'
)
}}
.col-auto
q-btn.acrylic-btn.q-mr-sm(
icon='las la-question-circle'
...
...
@@ -19,16 +19,16 @@ q-page.admin-navigation
icon='las la-redo-alt'
flat
color='secondary'
:loading='loading > 0'
:loading='
state.
loading > 0'
@click='load'
)
q-btn(
unelevated
icon='
mdi
-check'
:label='
$
t(`common.actions.apply`)'
icon='
fa-solid fa
-check'
:label='t(`common.actions.apply`)'
color='secondary'
@click='save'
:disabled='loading > 0'
:disabled='
state.
loading > 0'
)
q-separator(inset)
.row.q-pa-md.q-col-gutter-md
...
...
@@ -306,278 +306,297 @@ q-page.admin-navigation
//- page-selector(mode='select', v-model='selectPageModal', :open-handler='selectPageHandle', path='home', :locale='currentLang')
<
/template
>
<
script
>
import
_
from
'lodash'
<
script
setup
>
import
gql
from
'graphql-tag'
import
{
find
,
intersectionBy
,
pull
,
unionBy
}
from
'lodash-es'
import
{
v4
as
uuid
}
from
'uuid'
import
{
createMetaMixin
}
from
'quasar'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
computed
,
onMounted
,
reactive
,
watch
,
nextTick
}
from
'vue'
import
{
useAdminStore
}
from
'src/stores/admin'
import
{
useSiteStore
}
from
'src/stores/site'
import
draggable
from
'vuedraggable'
// QUASAR
const
$q
=
useQuasar
()
// STORES
const
adminStore
=
useAdminStore
()
const
siteStore
=
useSiteStore
()
// I18N
const
{
t
}
=
useI18n
()
// META
useMeta
({
title
:
t
(
'admin.navigation.title'
)
}
)
// DATA
const
siteConfig
=
{
lang
:
'en'
}
const
siteLangs
=
[{
code
:
'en'
}
]
export
default
{
mixins
:
[
createMetaMixin
(
function
()
{
return
{
title
:
this
.
$t
(
'admin.navigation.title'
)
}
}
)
],
components
:
{
draggable
}
,
meta
()
{
return
{
title
:
this
.
$t
(
'admin.navigation.title'
)
}
const
state
=
reactive
({
loading
:
0
,
selectPageModal
:
false
,
trees
:
[],
current
:
{
}
,
currentLang
:
siteConfig
.
lang
,
groups
:
[],
copyFromLocaleDialogIsShown
:
false
,
config
:
{
mode
:
'NONE'
}
,
data
()
{
return
{
loading
:
false
,
selectPageModal
:
false
,
trees
:
[],
current
:
{
}
,
currentLang
:
siteConfig
.
lang
,
groups
:
[],
copyFromLocaleDialogIsShown
:
false
,
config
:
{
mode
:
'NONE'
}
,
allLocales
:
[],
copyFromLocaleCode
:
'en'
}
allLocales
:
[],
copyFromLocaleCode
:
'en'
}
)
// COMPUTED
const
navTypes
=
computed
(()
=>
([
{
text
:
t
(
'navigation.navType.external'
),
value
:
'external'
}
,
{
text
:
t
(
'navigation.navType.externalblank'
),
value
:
'externalblank'
}
,
{
text
:
t
(
'navigation.navType.home'
),
value
:
'home'
}
,
{
text
:
t
(
'navigation.navType.page'
),
value
:
'page'
}
//
{
text
:
t
(
'navigation.navType.searchQuery'
),
value
:
'search'
}
]))
const
locales
=
computed
(()
=>
{
return
intersectionBy
(
state
.
allLocales
,
unionBy
(
siteLangs
,
[{
code
:
'en'
}
,
{
code
:
siteConfig
.
lang
}
],
'code'
),
'code'
)
}
)
const
currentTree
=
computed
({
get
()
{
return
find
(
state
.
trees
,
[
'locale'
,
state
.
currentLang
])?.
items
||
[]
}
,
computed
:
{
navTypes
()
{
return
[
{
text
:
this
.
$t
(
'navigation.navType.external'
),
value
:
'external'
}
,
{
text
:
this
.
$t
(
'navigation.navType.externalblank'
),
value
:
'externalblank'
}
,
{
text
:
this
.
$t
(
'navigation.navType.home'
),
value
:
'home'
}
,
{
text
:
this
.
$t
(
'navigation.navType.page'
),
value
:
'page'
}
//
{
text
:
this
.
$t
(
'navigation.navType.searchQuery'
),
value
:
'search'
}
]
}
,
locales
()
{
return
_
.
intersectionBy
(
this
.
allLocales
,
_
.
unionBy
(
siteLangs
,
[{
code
:
'en'
}
,
{
code
:
siteConfig
.
lang
}
],
'code'
),
'code'
)
}
,
currentTree
:
{
get
()
{
return
_
.
get
(
_
.
find
(
this
.
trees
,
[
'locale'
,
this
.
currentLang
]),
'items'
,
null
)
||
[]
}
,
set
(
val
)
{
const
tree
=
_
.
find
(
this
.
trees
,
[
'locale'
,
this
.
currentLang
])
if
(
tree
)
{
tree
.
items
=
val
}
else
{
this
.
trees
=
[...
this
.
trees
,
{
locale
:
this
.
currentLang
,
items
:
val
}
]
}
}
set
(
val
)
{
const
tree
=
find
(
state
.
trees
,
[
'locale'
,
state
.
currentLang
])
if
(
tree
)
{
tree
.
items
=
val
}
else
{
state
.
trees
=
[...
state
.
trees
,
{
locale
:
state
.
currentLang
,
items
:
val
}
]
}
}
,
watch
:
{
currentLang
(
newValue
,
oldValue
)
{
this
.
$nextTick
(()
=>
{
if
(
this
.
currentTree
.
length
>
0
)
{
this
.
current
=
this
.
currentTree
[
0
]
}
else
{
this
.
current
=
{
}
}
}
)
}
}
)
// WATCHERS
watch
(()
=>
state
.
currentLang
,
(
newValue
,
oldValue
)
=>
{
nextTick
(()
=>
{
if
(
state
.
currentTree
.
length
>
0
)
{
state
.
current
=
state
.
currentTree
[
0
]
}
else
{
state
.
current
=
{
}
}
}
,
methods
:
{
async
load
()
{
}
,
addItem
(
kind
)
{
let
newItem
=
{
id
:
uuid
(),
kind
,
visibilityMode
:
'all'
,
visibilityGroups
:
[]
}
switch
(
kind
)
{
case
'link'
:
newItem
=
{
...
newItem
,
label
:
this
.
$t
(
'navigation.untitled'
,
{
kind
:
this
.
$t
(
'navigation.link'
)
}
),
icon
:
'mdi-chevron-right'
,
targetType
:
'home'
,
target
:
''
}
break
case
'header'
:
newItem
.
label
=
this
.
$t
(
'navigation.untitled'
,
{
kind
:
this
.
$t
(
'navigation.header'
)
}
)
break
}
)
}
)
// METHODS
async
function
load
()
{
}
function
addItem
(
kind
)
{
let
newItem
=
{
id
:
uuid
(),
kind
,
visibilityMode
:
'all'
,
visibilityGroups
:
[]
}
switch
(
kind
)
{
case
'link'
:
newItem
=
{
...
newItem
,
label
:
t
(
'navigation.untitled'
,
{
kind
:
t
(
'navigation.link'
)
}
),
icon
:
'mdi-chevron-right'
,
targetType
:
'home'
,
target
:
''
}
this
.
currentTree
=
[...
this
.
currentTree
,
newItem
]
this
.
current
=
newItem
}
,
deleteItem
(
item
)
{
this
.
currentTree
=
_
.
pull
(
this
.
currentTree
,
item
)
this
.
current
=
{
}
}
,
selectItem
(
item
)
{
this
.
current
=
item
}
,
selectPage
()
{
this
.
selectPageModal
=
true
}
,
selectPageHandle
({
path
,
locale
}
)
{
this
.
current
.
target
=
`/${locale
}
/${path
}
`
}
,
copyFromLocale
()
{
this
.
copyFromLocaleDialogIsShown
=
false
this
.
currentTree
=
[...
this
.
currentTree
,
...
_
.
get
(
_
.
find
(
this
.
trees
,
[
'locale'
,
this
.
copyFromLocaleCode
]),
'items'
,
null
)
||
[]]
}
,
async
save
()
{
this
.
$store
.
commit
(
'loadingStart'
,
'admin-navigation-save'
)
try
{
const
resp
=
await
this
.
$apollo
.
mutate
({
mutation
:
gql
`
mutation ($tree: [NavigationTreeInput]!, $mode: NavigationMode!) {
navigation{
updateTree(tree: $tree) {
responseResult {
succeeded
errorCode
slug
message
}
}
,
updateConfig(mode: $mode) {
responseResult {
succeeded
errorCode
slug
message
}
}
break
case
'header'
:
newItem
.
label
=
t
(
'navigation.untitled'
,
{
kind
:
t
(
'navigation.header'
)
}
)
break
}
state
.
currentTree
=
[...
state
.
currentTree
,
newItem
]
state
.
current
=
newItem
}
function
deleteItem
(
item
)
{
state
.
currentTree
=
pull
(
state
.
currentTree
,
item
)
state
.
current
=
{
}
}
function
selectItem
(
item
)
{
state
.
current
=
item
}
function
selectPage
()
{
state
.
selectPageModal
=
true
}
function
selectPageHandle
({
path
,
locale
}
)
{
state
.
current
.
target
=
`/${locale
}
/${path
}
`
}
function
copyFromLocale
()
{
state
.
copyFromLocaleDialogIsShown
=
false
state
.
currentTree
=
[...
state
.
currentTree
,
...
find
(
state
.
trees
,
[
'locale'
,
state
.
copyFromLocaleCode
])?.
items
||
[]]
}
async
function
save
()
{
this
.
$store
.
commit
(
'loadingStart'
,
'admin-navigation-save'
)
try
{
const
resp
=
await
APOLLO_CLIENT
.
mutate
({
mutation
:
gql
`
mutation ($tree: [NavigationTreeInput]!, $mode: NavigationMode!) {
navigation{
updateTree(tree: $tree) {
responseResult {
succeeded
errorCode
slug
message
}
}
,
updateConfig(mode: $mode) {
responseResult {
succeeded
errorCode
slug
message
}
}
`
,
variables
:
{
tree
:
this
.
trees
,
mode
:
this
.
config
.
mode
}
}
)
if
(
_
.
get
(
resp
,
'data.navigation.updateTree.responseResult.succeeded'
,
false
)
&&
_
.
get
(
resp
,
'data.navigation.updateConfig.responseResult.succeeded'
,
false
))
{
this
.
$store
.
commit
(
'showNotification'
,
{
message
:
this
.
$t
(
'navigation.saveSuccess'
),
style
:
'success'
,
icon
:
'check'
}
)
}
else
{
throw
new
Error
(
_
.
get
(
resp
,
'data.navigation.updateTree.responseResult.message'
,
'An unexpected error occurred.'
))
}
}
catch
(
err
)
{
this
.
$store
.
commit
(
'pushGraphError'
,
err
)
`
,
variables
:
{
tree
:
state
.
trees
,
mode
:
state
.
config
.
mode
}
this
.
$store
.
commit
(
'loadingStop'
,
'admin-navigation-save'
)
}
,
async
refresh
()
{
await
this
.
$apollo
.
queries
.
trees
.
refetch
()
this
.
current
=
{
}
}
)
if
(
resp
?.
data
.
navigation
.
updateTree
.
responseResult
.
succeeded
&&
resp
?.
data
.
navigation
.
updateConfig
.
responseResult
.
succeeded
)
{
this
.
$store
.
commit
(
'showNotification'
,
{
message
:
'Navigation has been refreshed.'
,
message
:
t
(
'navigation.saveSuccess'
)
,
style
:
'success'
,
icon
:
'c
ached
'
icon
:
'c
heck
'
}
)
}
else
{
throw
new
Error
(
resp
?.
data
.
navigation
.
updateTree
.
operation
.
message
||
'An unexpected error occurred.'
)
}
}
catch
(
err
)
{
this
.
$store
.
commit
(
'pushGraphError'
,
err
)
}
// apollo:
{
// config:
{
// query: gql`
//
{
// navigation
{
// config
{
// mode
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.navigation.config),
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-config')
//
}
//
}
,
// trees: {
// query: gql`
//
{
// navigation
{
// tree
{
// locale
// items
{
// id
// kind
// label
// icon
// targetType
// target
// visibilityMode
// visibilityGroups
//
}
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.navigation.tree),
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-tree')
//
}
//
}
,
// groups: {
// query: gql`
// query
{
// groups
{
// list
{
// id
// name
// isSystem
// userCount
// createdAt
// updatedAt
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => data.groups.list,
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-groups')
//
}
//
}
,
// allLocales: {
// query: gql`
//
{
// localization
{
// locales
{
// code
// name
// nativeName
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => data.localization.locales,
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-locales')
//
}
//
}
//
}
this
.
$store
.
commit
(
'loadingStop'
,
'admin-navigation-save'
)
}
async
function
refresh
()
{
load
()
state
.
current
=
{
}
this
.
$store
.
commit
(
'showNotification'
,
{
message
:
'Navigation has been refreshed.'
,
style
:
'success'
,
icon
:
'cached'
}
)
}
// apollo:
{
// config:
{
// query: gql`
//
{
// navigation
{
// config
{
// mode
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.navigation.config),
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-config')
//
}
//
}
,
// trees: {
// query: gql`
//
{
// navigation
{
// tree
{
// locale
// items
{
// id
// kind
// label
// icon
// targetType
// target
// visibilityMode
// visibilityGroups
//
}
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.navigation.tree),
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-tree')
//
}
//
}
,
// groups: {
// query: gql`
// query
{
// groups
{
// list
{
// id
// name
// isSystem
// userCount
// createdAt
// updatedAt
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => data.groups.list,
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-groups')
//
}
//
}
,
// allLocales: {
// query: gql`
//
{
// localization
{
// locales
{
// code
// name
// nativeName
//
}
//
}
//
}
// `,
// fetchPolicy: 'network-only',
// update: (data) => data.localization.locales,
// watchLoading (isLoading)
{
// this.$store.commit(`loading$
{
isLoading
?
'Start'
:
'Stop'
}
`, 'admin-navigation-locales')
//
}
//
}
//
}
</script>
<style lang='scss' scoped>
.clickable {
cursor: pointer;
...
...
ux/src/pages/AdminSecurity.vue
View file @
4675348c
...
...
@@ -323,9 +323,8 @@ q-page.admin-mail
</
template
>
<
script
setup
>
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
gql
from
'graphql-tag'
import
_get
from
'lodash/get'
import
filesize
from
'filesize'
import
filesizeParser
from
'filesize-parser'
...
...
@@ -492,7 +491,7 @@ async function save () {
uploadMaxFileSize
:
filesizeParser
(
state
.
humanUploadMaxFileSize
||
'0'
)
}
})
const
resp
=
_get
(
respRaw
,
'data.updateSystemSecurity.status'
,
{})
const
resp
=
respRaw
?.
data
?.
updateSystemSecurity
?.
status
||
{}
if
(
resp
.
succeeded
)
{
$q
.
notify
({
type
:
'positive'
,
...
...
ux/src/pages/AdminStorage.vue
View file @
4675348c
...
...
@@ -583,10 +583,8 @@ q-page.admin-storage
</
template
>
<
script
setup
>
import
find
from
'lodash/find'
import
cloneDeep
from
'lodash/cloneDeep'
import
{
cloneDeep
,
find
,
transform
}
from
'lodash-es'
import
gql
from
'graphql-tag'
import
transform
from
'lodash/transform'
import
*
as
VNetworkGraph
from
'v-network-graph'
import
{
useI18n
}
from
'vue-i18n'
...
...
ux/src/pages/AdminSystem.vue
View file @
4675348c
...
...
@@ -227,7 +227,7 @@ q-page.admin-system
</
template
>
<
script
setup
>
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
gql
from
'graphql-tag'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
...
...
ux/src/pages/AdminTheme.vue
View file @
4675348c
...
...
@@ -203,8 +203,7 @@ q-page.admin-theme
<
script
setup
>
import
gql
from
'graphql-tag'
import
cloneDeep
from
'lodash/cloneDeep'
import
startCase
from
'lodash/startCase'
import
{
cloneDeep
,
startCase
}
from
'lodash-es'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
import
{
onMounted
,
reactive
,
watch
}
from
'vue'
...
...
ux/src/pages/AdminUsers.vue
View file @
4675348c
...
...
@@ -106,7 +106,7 @@ q-page.admin-groups
<
script
setup
>
import
gql
from
'graphql-tag'
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
{
DateTime
}
from
'luxon'
import
{
useI18n
}
from
'vue-i18n'
import
{
useMeta
,
useQuasar
}
from
'quasar'
...
...
ux/src/pages/AdminWebhooks.vue
View file @
4675348c
...
...
@@ -92,7 +92,7 @@ q-page.admin-webhooks
</
template
>
<
script
setup
>
import
cloneDeep
from
'lodash/cloneDeep
'
import
{
cloneDeep
}
from
'lodash-es
'
import
gql
from
'graphql-tag'
import
{
useI18n
}
from
'vue-i18n'
...
...
ux/src/router/routes.js
View file @
4675348c
...
...
@@ -35,12 +35,12 @@ const routes = [
{
path
:
':siteid/editors'
,
component
:
()
=>
import
(
'../pages/AdminEditors.vue'
)
},
{
path
:
':siteid/locale'
,
component
:
()
=>
import
(
'../pages/AdminLocale.vue'
)
},
{
path
:
':siteid/login'
,
component
:
()
=>
import
(
'../pages/AdminLogin.vue'
)
},
//
{ path: ':siteid/navigation', component: () => import('../pages/AdminNavigation.vue') },
{
path
:
':siteid/navigation'
,
component
:
()
=>
import
(
'../pages/AdminNavigation.vue'
)
},
// { path: ':siteid/rendering', component: () => import('../pages/AdminRendering.vue') },
{
path
:
':siteid/storage/:id?'
,
component
:
()
=>
import
(
'../pages/AdminStorage.vue'
)
},
{
path
:
':siteid/theme'
,
component
:
()
=>
import
(
'../pages/AdminTheme.vue'
)
},
// -> Users
//
{ path: 'auth', component: () => import('../pages/AdminAuth.vue') },
{
path
:
'auth'
,
component
:
()
=>
import
(
'../pages/AdminAuth.vue'
)
},
{
path
:
'groups/:id?/:section?'
,
component
:
()
=>
import
(
'../pages/AdminGroups.vue'
)
},
{
path
:
'users/:id?/:section?'
,
component
:
()
=>
import
(
'../pages/AdminUsers.vue'
)
},
// -> System
...
...
ux/yarn.lock
View file @
4675348c
This diff was suppressed by a .gitattributes entry.
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment