webui: Add dashboard widgets

This commit is contained in:
Kevin Kandlbinder 2022-03-29 15:33:58 +02:00
parent 2d68e30ad2
commit 00f4208ad2
29 changed files with 1443 additions and 174 deletions

View file

@ -154,6 +154,7 @@ type ComplexityRoot struct {
Debug func(childComplexity int) int Debug func(childComplexity int) int
HashCheckerConfig func(childComplexity int) int HashCheckerConfig func(childComplexity int) int
ID func(childComplexity int) int ID func(childComplexity int) int
Name func(childComplexity int) int
RoomID func(childComplexity int) int RoomID func(childComplexity int) int
} }
@ -814,6 +815,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Room.ID(childComplexity), true return e.complexity.Room.ID(childComplexity), true
case "Room.name":
if e.complexity.Room.Name == nil {
break
}
return e.complexity.Room.Name(childComplexity), true
case "Room.roomId": case "Room.roomId":
if e.complexity.Room.RoomID == nil { if e.complexity.Room.RoomID == nil {
break break
@ -1024,6 +1032,7 @@ type HashCheckerConfig {
type Room { type Room {
id: ID! id: ID!
active: Boolean! active: Boolean!
name: String!
roomId: String! roomId: String!
debug: Boolean! debug: Boolean!
adminPowerLevel: Int! adminPowerLevel: Int!
@ -4694,6 +4703,41 @@ func (ec *executionContext) _Room_active(ctx context.Context, field graphql.Coll
return ec.marshalNBoolean2bool(ctx, field.Selections, res) return ec.marshalNBoolean2bool(ctx, field.Selections, res)
} }
func (ec *executionContext) _Room_name(ctx context.Context, field graphql.CollectedField, obj *model.Room) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Room",
Field: field,
Args: nil,
IsMethod: false,
IsResolver: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Name, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(string)
fc.Result = res
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) _Room_roomId(ctx context.Context, field graphql.CollectedField, obj *model.Room) (ret graphql.Marshaler) { func (ec *executionContext) _Room_roomId(ctx context.Context, field graphql.CollectedField, obj *model.Room) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -8225,6 +8269,11 @@ func (ec *executionContext) _Room(ctx context.Context, sel ast.SelectionSet, obj
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
invalids++ invalids++
} }
case "name":
out.Values[i] = ec._Room_name(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "roomId": case "roomId":
out.Values[i] = ec._Room_roomId(ctx, field, obj) out.Values[i] = ec._Room_roomId(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {

View file

@ -5,6 +5,7 @@ import "github.com/Unkn0wnCat/matrix-veles/internal/config"
type Room struct { type Room struct {
ID string `json:"id"` ID string `json:"id"`
Active bool `json:"active"` Active bool `json:"active"`
Name string `json:"name"`
RoomID string `json:"roomId"` RoomID string `json:"roomId"`
Debug bool `json:"debug"` Debug bool `json:"debug"`
AdminPowerLevel int `json:"adminPowerLevel"` AdminPowerLevel int `json:"adminPowerLevel"`
@ -25,7 +26,8 @@ func MakeRoom(room *config.RoomConfig) *Room {
} }
return &Room{ return &Room{
ID: room.ID.String(), ID: room.ID.Hex(),
Name: room.Name,
Active: room.Active, Active: room.Active,
RoomID: room.RoomID, RoomID: room.RoomID,
Debug: room.Debug, Debug: room.Debug,

View file

@ -46,6 +46,7 @@ type HashCheckerConfig {
type Room { type Room {
id: ID! id: ID!
active: Boolean! active: Boolean!
name: String!
roomId: String! roomId: String!
debug: Boolean! debug: Boolean!
adminPowerLevel: Int! adminPowerLevel: Int!

View file

@ -100,13 +100,13 @@ func Run() {
// Set up async tasks // Set up async tasks
go startSync(matrixClient) go startSync(matrixClient)
go doInitialUpdate(matrixClient) go doInitialUpdate(matrixClient)
go doAdminStateUpdate(matrixClient) go doRoomStateUpdate(matrixClient)
go func() { go func() {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
for { for {
go doAdminStateUpdate(matrixClient) go doRoomStateUpdate(matrixClient)
<-ticker.C <-ticker.C
} }
}() }()
@ -188,8 +188,8 @@ func doInitialUpdate(matrixClient *mautrix.Client) {
config.RoomConfigInitialUpdate(resp.JoinedRooms, ctx) config.RoomConfigInitialUpdate(resp.JoinedRooms, ctx)
} }
func doAdminStateUpdate(matrixClient *mautrix.Client) { func doRoomStateUpdate(matrixClient *mautrix.Client) {
ctx, span := tracer.Tracer.Start(tracer.Ctx, "admin_state_update") ctx, span := tracer.Tracer.Start(tracer.Ctx, "room_state_update")
defer span.End() defer span.End()
_, requestSpan := tracer.Tracer.Start(ctx, "request_joined_rooms") _, requestSpan := tracer.Tracer.Start(ctx, "request_joined_rooms")
@ -222,8 +222,14 @@ func doAdminStateUpdate(matrixClient *mautrix.Client) {
} }
} }
state, _ := GetRoomNameState(matrixClient, roomId)
roomConfig = config.GetRoomConfig(roomId.String()) roomConfig = config.GetRoomConfig(roomId.String())
roomConfig.Admins = admins roomConfig.Admins = admins
roomConfig.Name = roomId.String()
if state != nil && state.Name != "" {
roomConfig.Name = state.Name
}
err = config.SaveRoomConfig(&roomConfig) err = config.SaveRoomConfig(&roomConfig)
if err != nil { if err != nil {
processSpan.RecordError(err) processSpan.RecordError(err)

View file

@ -84,3 +84,59 @@ func GetRoomPowerLevelState(matrixClient *mautrix.Client, roomId id.RoomID) (*St
return &plEventContent, nil return &plEventContent, nil
} }
type StateEventRoomName struct {
Type string `json:"type"`
Sender string `json:"sender"`
RoomID string `json:"room_id"`
EventID string `json:"event_id"`
OriginServerTS int64 `json:"origin_server_ts"`
Content StateEventRoomNameContent `json:"content"`
Unsigned struct {
Age int `json:"age"`
} `json:"unsigned"`
}
type StateEventRoomNameContent struct {
Name string
}
func GetRoomNameState(matrixClient *mautrix.Client, roomId id.RoomID) (*StateEventRoomNameContent, error) {
// https://matrix.example.com/_matrix/client/r0/rooms/<roomId.String()>/state
url := matrixClient.BuildURL("rooms", roomId.String(), "state")
res, err := matrixClient.MakeRequest("GET", url, nil, nil)
if err != nil {
return nil, fmt.Errorf("ERROR: Could request room state - %v", err)
}
// res contains an array of state events
var stateEvents []StateEventRoomName
err = json.Unmarshal(res, &stateEvents)
if err != nil {
return nil, fmt.Errorf("ERROR: Could parse room state - %v", err)
}
// plEventContent will hold the final event
var plEventContent StateEventRoomNameContent
found := false
for _, e2 := range stateEvents {
if e2.Type != event.StateRoomName.Type {
continue // If the current event is not of the room name, skip.
}
// This is what we're looking for!
found = true
plEventContent = e2.Content
break
}
if !found {
return nil, fmt.Errorf("ERROR: Could find room power level - %v", err)
}
return &plEventContent, nil
}

View file

@ -29,6 +29,9 @@ type RoomConfig struct {
// Active tells if the bot is active in this room (Set to false on leave/kick/ban) // Active tells if the bot is active in this room (Set to false on leave/kick/ban)
Active bool `yaml:"active" bson:"active"` Active bool `yaml:"active" bson:"active"`
// Name is fetched regularly from the room state
Name string `yaml:"name" bson:"name"`
// RoomID is the rooms ID // RoomID is the rooms ID
RoomID string `yaml:"roomID" bson:"room_id"` RoomID string `yaml:"roomID" bson:"room_id"`

View file

@ -46,6 +46,7 @@ type HashCheckerConfig {
type Room { type Room {
id: ID! id: ID!
active: Boolean! active: Boolean!
name: String!
roomId: String! roomId: String!
debug: Boolean! debug: Boolean!
adminPowerLevel: Int! adminPowerLevel: Int!
@ -316,6 +317,11 @@ input HashCheckerConfigUpdate {
hashCheckMode: HashCheckerMode hashCheckMode: HashCheckerMode
} }
input ListSubscriptionUpdate {
roomId: ID!
listId: ID!
}
type Mutation { type Mutation {
login(input: Login!): String! login(input: Login!): String!
register(input: Register!): String! @hasRole(role: UNAUTHENTICATED) register(input: Register!): String! @hasRole(role: UNAUTHENTICATED)
@ -323,6 +329,8 @@ type Mutation {
removeMXID(input: RemoveMXID!): User! @loggedIn removeMXID(input: RemoveMXID!): User! @loggedIn
reconfigureRoom(input: RoomConfigUpdate!): Room! @loggedIn reconfigureRoom(input: RoomConfigUpdate!): Room! @loggedIn
subscribeToList(input: ListSubscriptionUpdate!): Room! @loggedIn
unsubscribeFromList(input: ListSubscriptionUpdate!): Room! @loggedIn
createEntry(input: CreateEntry!): Entry! @loggedIn createEntry(input: CreateEntry!): Entry! @loggedIn
commentEntry(input: CommentEntry!): Entry! @loggedIn commentEntry(input: CommentEntry!): Entry! @loggedIn

View file

@ -1,5 +1,17 @@
{ {
"list": {
"none": "Keine Einträge",
"loading": "Lade weitere Einträge...",
"more": "Mehr zeigen",
"end": "Keine weiteren Einträge"
},
"dashboard": { "dashboard": {
"helloText": "Ayo {{name}}!" "helloText": "Ayo {{name}}!",
"my_lists": {
"title": "Meine Listen"
},
"my_rooms": {
"title": "Meine Räume"
}
} }
} }

View file

@ -1,5 +1,17 @@
{ {
"list": {
"none": "No entries",
"loading": "Loading more entries...",
"more": "Show more",
"end": "No more entries"
},
"dashboard": { "dashboard": {
"helloText": "Ayo {{name}}!" "helloText": "Ayo {{name}}!",
"my_lists": {
"title": "My Lists"
},
"my_rooms": {
"title": "My Rooms"
}
} }
} }

View file

@ -12,8 +12,8 @@ import {useTranslation} from "react-i18next";
import { import {
useQueryLoader, useRelayEnvironment, useQueryLoader, useRelayEnvironment,
} from 'react-relay/hooks'; } from 'react-relay/hooks';
import Dashboard from "./components/dashboard/Dashboard"; import Dashboard from "./components/panel/dashboard/Dashboard";
import DashboardQueryGraphql, {DashboardQuery} from "./components/dashboard/__generated__/DashboardQuery.graphql"; import DashboardQueryGraphql, {DashboardQuery} from "./components/panel/dashboard/__generated__/DashboardQuery.graphql";
function App() { function App() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -52,15 +52,16 @@ function App() {
<Route path={"register"} element={<RegisterView/>} /> <Route path={"register"} element={<RegisterView/>} />
</Route> </Route>
<Route path={"/"} element={<PanelLayout/>}> <Route path={"/"} element={<PanelLayout/>}>
<Route path={""} element={<RequireAuth>{/*<h1><Trans i18nKey={"test"}>Test</Trans></h1> <button onClick={() => { <Route path={""} element={<RequireAuth>{dashboardInitialState && <Dashboard initialQueryRef={dashboardInitialState}/>}</RequireAuth>} />
dispatch(logOut()) <Route path={"rooms"} element={<RequireAuth><h1>rooms</h1></RequireAuth>}>
} <Route path={":id"} element={<h1>room detail</h1>} />
}>Log out</button> <p>{ </Route>
JSON.stringify(data.self) <Route path={"hashing/lists"} element={<RequireAuth><h1>lists</h1></RequireAuth>}>
}</p>*/}{dashboardInitialState && <Dashboard initialQueryRef={dashboardInitialState}/>}</RequireAuth>} /> <Route path={":id"} element={<h1>list detail</h1>} />
<Route path={"rooms"} element={<RequireAuth><h1>rooms</h1></RequireAuth>} /> </Route>
<Route path={"hashing/lists"} element={<RequireAuth><h1>lists</h1></RequireAuth>} /> <Route path={"hashing/entries"} element={<RequireAuth><h1>entries</h1></RequireAuth>}>
<Route path={"hashing/entries"} element={<RequireAuth><h1>entries</h1></RequireAuth>} /> <Route path={":id"} element={<h1>entry detail</h1>} />
</Route>
</Route> </Route>
</Routes> </Routes>
); );

View file

@ -0,0 +1,56 @@
@import "../globals";
.list {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
> * {
margin-bottom: var(--veles-layout-padding);
}
.eol {
display: block;
text-align: center;
padding: var(--veles-layout-padding);
opacity: .5;
margin-bottom: 0;
}
.loadMore {
display: block;
text-align: center;
padding: var(--veles-layout-padding);
background-color: transparent;
border: none;
cursor: pointer;
font: inherit;
color: inherit;
margin-bottom: 0;
}
.loader {
margin: 0 auto;
padding: var(--veles-layout-padding);
display: flex;
justify-content: center;
align-items: center;
> svg {
animation-name: spinny-spin;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
}
}
@keyframes spinny-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,32 @@
import React, {useCallback} from "react";
import styles from "./List.module.scss";
import {Box, Loader} from "lucide-react";
import {Trans, useTranslation} from "react-i18next";
import {Link} from "react-router-dom";
import {LoadMoreFn} from "react-relay/relay-hooks/useLoadMoreFunction";
type Props = {
children?: React.ReactNodeArray
isLoadingNext?: boolean
hasNext?: boolean
loadNext?: LoadMoreFn<any>|(() => any)
className?: string
}
const List = (props: Props) => {
const {t} = useTranslation()
return <div className={styles.list + (props.className ? " " + props.className :"")}>
{(!props.children || props.children.length == 0) && <span className={styles.eol}>
<Box width={50} height={50} strokeWidth={1} strokeDasharray={"2px 4px"} /><br/>
<Trans i18nKey={"list.none"}>No entries</Trans>
</span>}
{props.children}
{props.isLoadingNext && <div className={styles.loader} title={t("list.loading", "Loading more entries...")}><Loader/></div>}
{!props.isLoadingNext && props.children && props.children.length > 0 && (props.hasNext ? <button className={styles.loadMore} onClick={() => props.loadNext}><Trans i18nKey={"list.more"}>Show more</Trans></button> : <span className={styles.eol}><Trans i18nKey={"list.end"}>No more entries</Trans></span>)}
</div>
}
export default List

View file

@ -1,77 +0,0 @@
import React, {useCallback} from "react";
import {graphql} from "babel-plugin-relay/macro";
import {PreloadedQuery, usePaginationFragment, usePreloadedQuery} from "react-relay/hooks";
import {DashboardQuery} from "./__generated__/DashboardQuery.graphql";
import {Trans} from "react-i18next";
import {DashboardListsQuery} from "./__generated__/DashboardListsQuery.graphql";
import {DashboardQueryLists$data, DashboardQueryLists$key} from "./__generated__/DashboardQueryLists.graphql";
type Props = {
initialQueryRef: PreloadedQuery<DashboardQuery>,
}
const Dashboard = (props: Props) => {
const data = usePreloadedQuery<DashboardQuery>(
graphql`
query DashboardQuery($first: String, $count: Int) {
self {
username
id
admin
}
...DashboardQueryLists
}
`,
props.initialQueryRef
)
const {data: d2, hasNext, loadNext, refetch} = usePaginationFragment<DashboardListsQuery, DashboardQueryLists$key>(
graphql`
fragment DashboardQueryLists on Query @refetchable(queryName: "DashboardListsQuery") {
rooms(after: $first, first: $count, filter: {canEdit: true}) @connection(key: "DashboardQueryLists_rooms") {
edges {
node {
id
active
debug
hashCheckerConfig {
chatNotice
hashCheckMode
}
}
}
}
}
`,
// @ts-ignore
data
)
const refresh = useCallback(() => {
refetch({}, {fetchPolicy: "network-only"})
}, [])
const name = data.self?.username
return <>
<h1><Trans i18nKey={"dashboard.helloText"}>Ayo {{name}}!</Trans></h1>
{<button onClick={refresh}>Refresh</button>}
<pre>{
JSON.stringify(d2, null, 2)
}</pre>
{hasNext && <button
onClick={() => {
loadNext(2)
}}>
Load more Entries
</button>}
</>
}
export default Dashboard

View file

@ -0,0 +1,40 @@
@import "../../../globals";
.dashMyLists {
background-color: var(--veles-color-surface);
border: thin solid var(--veles-color-border);
padding: var(--veles-layout-padding);
padding-bottom: 0;
border-radius: var(--veles-layout-border-radius);
display: flex;
flex-direction: column;
> * {
flex-shrink: 0;
}
.list {
.listEntry {
background-color: var(--veles-color-surface);
padding: var(--veles-layout-padding);
border-radius: var(--veles-layout-border-radius);
display: flex;
flex-direction: column;
text-decoration: none;
.nameRow {
display: flex;
align-items: center;
.name {
font-weight: 600;
font-size: 1.2em;
}
}
.id {
opacity: .5;
}
}
}
}

View file

@ -0,0 +1,55 @@
import React from "react";
import {PreloadedQuery, usePaginationFragment} from "react-relay/hooks";
import {graphql} from "babel-plugin-relay/macro";
import {Trans, useTranslation} from "react-i18next";
import styles from "./DashMyLists.module.scss";
import {Link} from "react-router-dom";
import List from "../../List";
import {DashMyListsFragment$key} from "./__generated__/DashMyListsFragment.graphql";
import {ComponentDashMyLists} from "./__generated__/ComponentDashMyLists.graphql";
type Props = {
initialQueryRef: DashMyListsFragment$key,
className?: string,
}
const DashMyLists = (props: Props) => {
const {t} = useTranslation()
const {data, refetch, loadNext, hasNext, isLoadingNext} = usePaginationFragment<ComponentDashMyLists, DashMyListsFragment$key>(
graphql`
fragment DashMyListsFragment on Query @refetchable(queryName: "ComponentDashMyLists") {
lists(after: $first, first: $count) @connection(key: "ComponentDashMyLists_lists") {
edges {
node {
id
name
}
}
}
}
`,
props.initialQueryRef
)
return (
<div className={styles.dashMyLists + " " + (props.className || "")}>
<h2><Trans i18nKey={"dashboard.my_lists.title"}>My Lists</Trans></h2>
<List className={styles.list} hasNext={hasNext} isLoadingNext={isLoadingNext} loadNext={loadNext}>
{
data.lists?.edges.map((edge) => {
return <Link className={styles.listEntry} key={edge.node.id} to={"/hashing/lists/"+edge.node.id}>
<div className={styles.nameRow}>
<span className={styles.name}>{edge.node.name}</span>
</div>
<span className={styles.id}>{edge.node.id}</span>
</Link>
})
}
</List>
</div>
)
}
export default DashMyLists

View file

@ -0,0 +1,69 @@
@import "../../../globals";
.dashMyRooms {
background-color: var(--veles-color-surface);
border: thin solid var(--veles-color-border);
padding: var(--veles-layout-padding);
padding-bottom: 0;
border-radius: var(--veles-layout-border-radius);
display: flex;
flex-direction: column;
> * {
flex-shrink: 0;
}
.list {
.room {
background-color: var(--veles-color-surface);
padding: var(--veles-layout-padding);
border-radius: var(--veles-layout-border-radius);
display: flex;
flex-direction: column;
text-decoration: none;
.nameRow {
display: flex;
align-items: center;
.name {
font-weight: 600;
font-size: 1.2em;
}
.badge {
padding: 2px var(--veles-layout-padding-slim);
background-color: var(--veles-color-surface);
border-radius: var(--veles-layout-border-radius);
margin-left: 10px;
border: thin solid var(--veles-color-border);
&.red {
border-color: var(--veles-color-red);
}
&.blue {
border-color: var(--veles-color-blue);
}
&.green {
border-color: var(--veles-color-green);
}
}
}
.id {
opacity: .5;
}
}
}
}
@keyframes spinny-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,63 @@
import React from "react";
import {PreloadedQuery, usePaginationFragment} from "react-relay/hooks";
import {graphql} from "babel-plugin-relay/macro";
import {DashboardQuery} from "./__generated__/DashboardQuery.graphql";
import {DashMyRoomsFragment$key} from "./__generated__/DashMyRoomsFragment.graphql";
import {ComponentDashMyRooms} from "./__generated__/ComponentDashMyRooms.graphql";
import {Trans, useTranslation} from "react-i18next";
import styles from "./DashMyRooms.module.scss";
import {Link} from "react-router-dom";
import {Loader, Box} from "lucide-react";
import List from "../../List";
type Props = {
initialQueryRef: DashMyRoomsFragment$key,
className?: string,
}
const DashMyRooms = (props: Props) => {
const {t} = useTranslation()
const {data, refetch, loadNext, hasNext, isLoadingNext} = usePaginationFragment<ComponentDashMyRooms, DashMyRoomsFragment$key>(
graphql`
fragment DashMyRoomsFragment on Query @refetchable(queryName: "ComponentDashMyRooms") {
rooms(after: $first, first: $count, filter: {canEdit: true}) @connection(key: "ComponentDashMyRooms_rooms") {
edges {
node {
id
name
active
debug
roomId
}
}
}
}
`,
props.initialQueryRef
)
return (
<div className={styles.dashMyRooms + " " + (props.className || "")}>
<h2><Trans i18nKey={"dashboard.my_rooms.title"}>My Rooms</Trans></h2>
<List className={styles.list} hasNext={hasNext} isLoadingNext={isLoadingNext} loadNext={loadNext}>
{
data.rooms?.edges.map((edge) => {
return <Link className={styles.room} key={edge.node.id} to={"/rooms/"+edge.node.id}>
<div className={styles.nameRow}>
<span className={styles.name}>{edge.node.name}</span>
{edge.node.debug && <span className={styles.badge + " " + styles.blue}>Debug</span>}
{!edge.node.active && <span className={styles.badge + " " + styles.red}>Inactive</span>}
{edge.node.active && <span className={styles.badge + " " + styles.green}>Active</span>}
</div>
<span className={styles.id}>{edge.node.roomId}</span>
</Link>
})
}
</List>
</div>
)
}
export default DashMyRooms

View file

@ -0,0 +1,19 @@
@import "../../../globals";
.dashboardGrid {
height: 90%;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr;
gap: var(--veles-layout-padding);
@media (max-width: 1300px) {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
@media (max-width: 950px) {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr;
}
}

View file

@ -0,0 +1,48 @@
import React, {useCallback} from "react";
import {graphql} from "babel-plugin-relay/macro";
import {PreloadedQuery, usePaginationFragment, usePreloadedQuery} from "react-relay/hooks";
import {DashboardQuery} from "./__generated__/DashboardQuery.graphql";
import {Trans} from "react-i18next";
import {DashboardListsQuery} from "./__generated__/DashboardListsQuery.graphql";
import {DashboardQueryLists$data, DashboardQueryLists$key} from "./__generated__/DashboardQueryLists.graphql";
import DashMyRooms from "./DashMyRooms";
import styles from "./Dashboard.module.scss";
import DashMyLists from "./DashMyLists";
type Props = {
initialQueryRef: PreloadedQuery<DashboardQuery>,
}
const Dashboard = (props: Props) => {
const data = usePreloadedQuery<DashboardQuery>(
graphql`
query DashboardQuery($first: String, $count: Int) {
self {
username
id
admin
}
...DashMyRoomsFragment
...DashMyListsFragment
}
`,
props.initialQueryRef
)
const name = data.self?.username
return <>
<h1><Trans i18nKey={"dashboard.helloText"}>Ayo {{name}}!</Trans></h1>
<div className={styles.dashboardGrid}>
<DashMyRooms initialQueryRef={data}/>
<DashMyLists initialQueryRef={data}/>
</div>
</>
}
export default Dashboard

View file

@ -0,0 +1,182 @@
/**
* @generated SignedSource<<6d344122998b6cbd0c9e6d2b2f8d23bd>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest, Query } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ComponentDashMyLists$variables = {
count?: number | null;
first?: string | null;
};
export type ComponentDashMyLists$data = {
readonly " $fragmentSpreads": FragmentRefs<"DashMyListsFragment">;
};
export type ComponentDashMyLists = {
variables: ComponentDashMyLists$variables;
response: ComponentDashMyLists$data;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "count"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
}
],
v1 = [
{
"kind": "Variable",
"name": "after",
"variableName": "first"
},
{
"kind": "Variable",
"name": "first",
"variableName": "count"
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ComponentDashMyLists",
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "DashMyListsFragment"
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ComponentDashMyLists",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "ListConnection",
"kind": "LinkedField",
"name": "lists",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ListEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "List",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v1/*: any*/),
"filters": null,
"handle": "connection",
"key": "ComponentDashMyLists_lists",
"kind": "LinkedHandle",
"name": "lists"
}
]
},
"params": {
"cacheID": "56319a434d00fcd6406c4e9aa88de1fa",
"id": null,
"metadata": {},
"name": "ComponentDashMyLists",
"operationKind": "query",
"text": "query ComponentDashMyLists(\n $count: Int\n $first: String\n) {\n ...DashMyListsFragment\n}\n\nfragment DashMyListsFragment on Query {\n lists(after: $first, first: $count) {\n edges {\n node {\n id\n name\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
}
};
})();
(node as any).hash = "6f41b7c821add7cf4c8c37b343bd6d2d";
export default node;

View file

@ -0,0 +1,212 @@
/**
* @generated SignedSource<<a4ab3c123e20e4c610b878fd82d98eb5>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest, Query } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ComponentDashMyRooms$variables = {
count?: number | null;
first?: string | null;
};
export type ComponentDashMyRooms$data = {
readonly " $fragmentSpreads": FragmentRefs<"DashMyRoomsFragment">;
};
export type ComponentDashMyRooms = {
variables: ComponentDashMyRooms$variables;
response: ComponentDashMyRooms$data;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "count"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
}
],
v1 = [
{
"kind": "Variable",
"name": "after",
"variableName": "first"
},
{
"kind": "Literal",
"name": "filter",
"value": {
"canEdit": true
}
},
{
"kind": "Variable",
"name": "first",
"variableName": "count"
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ComponentDashMyRooms",
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "DashMyRoomsFragment"
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ComponentDashMyRooms",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "RoomConnection",
"kind": "LinkedField",
"name": "rooms",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "RoomEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Room",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "active",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "debug",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "roomId",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v1/*: any*/),
"filters": [
"filter"
],
"handle": "connection",
"key": "ComponentDashMyRooms_rooms",
"kind": "LinkedHandle",
"name": "rooms"
}
]
},
"params": {
"cacheID": "900fab96de8f0dd453020c4443727ed4",
"id": null,
"metadata": {},
"name": "ComponentDashMyRooms",
"operationKind": "query",
"text": "query ComponentDashMyRooms(\n $count: Int\n $first: String\n) {\n ...DashMyRoomsFragment\n}\n\nfragment DashMyRoomsFragment on Query {\n rooms(after: $first, first: $count, filter: {canEdit: true}) {\n edges {\n node {\n id\n name\n active\n debug\n roomId\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
}
};
})();
(node as any).hash = "fc18a3a1a32649d6d9fd694500167a4c";
export default node;

View file

@ -0,0 +1,163 @@
/**
* @generated SignedSource<<f487153d3e4df6d37d1c3f952fde6252>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment, RefetchableFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type DashMyListsFragment$data = {
readonly lists: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly name: string;
};
}>;
} | null;
readonly " $fragmentType": "DashMyListsFragment";
};
export type DashMyListsFragment$key = {
readonly " $data"?: DashMyListsFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"DashMyListsFragment">;
};
const node: ReaderFragment = (function(){
var v0 = [
"lists"
];
return {
"argumentDefinitions": [
{
"kind": "RootArgument",
"name": "count"
},
{
"kind": "RootArgument",
"name": "first"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": "count",
"cursor": "first",
"direction": "forward",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "count",
"cursor": "first"
},
"backward": null,
"path": (v0/*: any*/)
},
"fragmentPathInResult": [],
"operation": require('./ComponentDashMyLists.graphql')
}
},
"name": "DashMyListsFragment",
"selections": [
{
"alias": "lists",
"args": null,
"concreteType": "ListConnection",
"kind": "LinkedField",
"name": "__ComponentDashMyLists_lists_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ListEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "List",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
};
})();
(node as any).hash = "6f41b7c821add7cf4c8c37b343bd6d2d";
export default node;

View file

@ -0,0 +1,195 @@
/**
* @generated SignedSource<<4b90432feeb0d507f1f25105ee5c5bd5>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment, RefetchableFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type DashMyRoomsFragment$data = {
readonly rooms: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly name: string;
readonly active: boolean;
readonly debug: boolean;
readonly roomId: string;
};
}>;
} | null;
readonly " $fragmentType": "DashMyRoomsFragment";
};
export type DashMyRoomsFragment$key = {
readonly " $data"?: DashMyRoomsFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"DashMyRoomsFragment">;
};
const node: ReaderFragment = (function(){
var v0 = [
"rooms"
];
return {
"argumentDefinitions": [
{
"kind": "RootArgument",
"name": "count"
},
{
"kind": "RootArgument",
"name": "first"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": "count",
"cursor": "first",
"direction": "forward",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "count",
"cursor": "first"
},
"backward": null,
"path": (v0/*: any*/)
},
"fragmentPathInResult": [],
"operation": require('./ComponentDashMyRooms.graphql')
}
},
"name": "DashMyRoomsFragment",
"selections": [
{
"alias": "rooms",
"args": [
{
"kind": "Literal",
"name": "filter",
"value": {
"canEdit": true
}
}
],
"concreteType": "RoomConnection",
"kind": "LinkedField",
"name": "__ComponentDashMyRooms_rooms_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "RoomEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Room",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "active",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "debug",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "roomId",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "__ComponentDashMyRooms_rooms_connection(filter:{\"canEdit\":true})"
}
],
"type": "Query",
"abstractKey": null
};
})();
(node as any).hash = "fc18a3a1a32649d6d9fd694500167a4c";
export default node;

View file

@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<3a1cacd9d1f9fc8ae498211b0141af63>> * @generated SignedSource<<a34bf324ec46a1d657a8967d773745fe>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@ -20,7 +20,7 @@ export type DashboardQuery$data = {
readonly id: string; readonly id: string;
readonly admin: boolean | null; readonly admin: boolean | null;
} | null; } | null;
readonly " $fragmentSpreads": FragmentRefs<"DashboardQueryLists">; readonly " $fragmentSpreads": FragmentRefs<"DashMyRoomsFragment" | "DashMyListsFragment">;
}; };
export type DashboardQuery = { export type DashboardQuery = {
variables: DashboardQuery$variables; variables: DashboardQuery$variables;
@ -71,12 +71,18 @@ v3 = {
], ],
"storageKey": null "storageKey": null
}, },
v4 = [ v4 = {
{ "kind": "Variable",
"kind": "Variable", "name": "after",
"name": "after", "variableName": "first"
"variableName": "first" },
}, v5 = {
"kind": "Variable",
"name": "first",
"variableName": "count"
},
v6 = [
(v4/*: any*/),
{ {
"kind": "Literal", "kind": "Literal",
"name": "filter", "name": "filter",
@ -84,11 +90,57 @@ v4 = [
"canEdit": true "canEdit": true
} }
}, },
{ (v5/*: any*/)
"kind": "Variable", ],
"name": "first", v7 = {
"variableName": "count" "alias": null,
} "args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v9 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v10 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
v11 = [
(v4/*: any*/),
(v5/*: any*/)
]; ];
return { return {
"fragment": { "fragment": {
@ -104,7 +156,12 @@ return {
{ {
"args": null, "args": null,
"kind": "FragmentSpread", "kind": "FragmentSpread",
"name": "DashboardQueryLists" "name": "DashMyRoomsFragment"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "DashMyListsFragment"
} }
], ],
"type": "Query", "type": "Query",
@ -122,7 +179,7 @@ return {
(v3/*: any*/), (v3/*: any*/),
{ {
"alias": null, "alias": null,
"args": (v4/*: any*/), "args": (v6/*: any*/),
"concreteType": "RoomConnection", "concreteType": "RoomConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "rooms", "name": "rooms",
@ -145,6 +202,7 @@ return {
"plural": false, "plural": false,
"selections": [ "selections": [
(v2/*: any*/), (v2/*: any*/),
(v7/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@ -162,100 +220,93 @@ return {
{ {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "HashCheckerConfig", "kind": "ScalarField",
"kind": "LinkedField", "name": "roomId",
"name": "hashCheckerConfig",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "chatNotice",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hashCheckMode",
"storageKey": null
}
],
"storageKey": null "storageKey": null
}, },
{ (v8/*: any*/)
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
], ],
"storageKey": null "storageKey": null
}, },
{ (v9/*: any*/)
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
], ],
"storageKey": null "storageKey": null
}, },
{ (v10/*: any*/)
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
], ],
"storageKey": null "storageKey": null
}, },
{ {
"alias": null, "alias": null,
"args": (v4/*: any*/), "args": (v6/*: any*/),
"filters": [ "filters": [
"filter" "filter"
], ],
"handle": "connection", "handle": "connection",
"key": "DashboardQueryLists_rooms", "key": "ComponentDashMyRooms_rooms",
"kind": "LinkedHandle", "kind": "LinkedHandle",
"name": "rooms" "name": "rooms"
},
{
"alias": null,
"args": (v11/*: any*/),
"concreteType": "ListConnection",
"kind": "LinkedField",
"name": "lists",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ListEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "List",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v7/*: any*/),
(v8/*: any*/)
],
"storageKey": null
},
(v9/*: any*/)
],
"storageKey": null
},
(v10/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": (v11/*: any*/),
"filters": null,
"handle": "connection",
"key": "ComponentDashMyLists_lists",
"kind": "LinkedHandle",
"name": "lists"
} }
] ]
}, },
"params": { "params": {
"cacheID": "f41a91749c22f4e9026e9ae1d21e72a9", "cacheID": "72b0fa7a61819018fd38de7564f1f3cb",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "DashboardQuery", "name": "DashboardQuery",
"operationKind": "query", "operationKind": "query",
"text": "query DashboardQuery(\n $first: String\n $count: Int\n) {\n self {\n username\n id\n admin\n }\n ...DashboardQueryLists\n}\n\nfragment DashboardQueryLists on Query {\n rooms(after: $first, first: $count, filter: {canEdit: true}) {\n edges {\n node {\n id\n active\n debug\n hashCheckerConfig {\n chatNotice\n hashCheckMode\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n" "text": "query DashboardQuery(\n $first: String\n $count: Int\n) {\n self {\n username\n id\n admin\n }\n ...DashMyRoomsFragment\n ...DashMyListsFragment\n}\n\nfragment DashMyListsFragment on Query {\n lists(after: $first, first: $count) {\n edges {\n node {\n id\n name\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment DashMyRoomsFragment on Query {\n rooms(after: $first, first: $count, filter: {canEdit: true}) {\n edges {\n node {\n id\n name\n active\n debug\n roomId\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "dc06e10a331a27f1a5904147ae650a71"; (node as any).hash = "5a8ab0cabd608a79789053ba85880813";
export default node; export default node;

View file

@ -26,7 +26,12 @@ html, body, #root {
--veles-color-accent: #007300; --veles-color-accent: #007300;
--veles-color-error: #e3373e; --veles-color-error: #e3373e;
--veles-color-red: #e3373e;
--veles-color-blue: #37a7e3;
--veles-color-green: #5ce337;
--veles-layout-padding: 20px; --veles-layout-padding: 20px;
--veles-layout-padding-inverse: -20px;
--veles-layout-padding-slim: 10px; --veles-layout-padding-slim: 10px;
--veles-layout-padding-wide: 40px; --veles-layout-padding-wide: 40px;
--veles-layout-border-radius: 10px; --veles-layout-border-radius: 10px;
@ -42,6 +47,12 @@ html, body, #root {
} }
} }
h1, h2, h3, h4, h5, h6 {
&:first-child {
margin-top: 0;
}
}
a { a {
color: inherit; color: inherit;
text-decoration: underline dotted currentColor; text-decoration: underline dotted currentColor;