init: working version
This commit is contained in:
commit
7d8d7dacae
109 changed files with 15066 additions and 0 deletions
29
modules/dashboard/dash/DateTime.qml
Normal file
29
modules/dashboard/dash/DateTime.qml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.services
|
||||
import qs.config
|
||||
import qs.custom
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: -2
|
||||
spacing: -6
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: Time.format("hh:mm:ss")
|
||||
color: Config.colors.secondary
|
||||
font.family: Config.font.family.mono
|
||||
font.pointSize: Config.font.size.largest
|
||||
font.weight: 600
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: Time.format("dddd, yyyy-MM-dd")
|
||||
color: Config.colors.tertiary
|
||||
font.pointSize: Config.font.size.normal
|
||||
}
|
||||
}
|
||||
243
modules/dashboard/dash/Media.qml
Normal file
243
modules/dashboard/dash/Media.qml
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import qs.config
|
||||
import qs.custom
|
||||
import qs.services
|
||||
import qs.util
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
property real playerProgress: {
|
||||
const active = Players.active;
|
||||
return active?.length ? active.position / active.length : 0;
|
||||
}
|
||||
|
||||
Behavior on playerProgress {
|
||||
Anim {
|
||||
duration: Config.anim.durations.large
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
running: Players.active?.isPlaying ?? false
|
||||
interval: 400
|
||||
triggeredOnStart: true
|
||||
repeat: true
|
||||
onTriggered: Players.active?.positionChanged()
|
||||
}
|
||||
|
||||
Shape {
|
||||
id: progress
|
||||
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
readonly property int thickness: 8
|
||||
readonly property int angle: 300
|
||||
|
||||
ShapePath {
|
||||
id: path
|
||||
|
||||
fillColor: "transparent"
|
||||
strokeColor: Qt.alpha(Config.colors.media, 0.2)
|
||||
strokeWidth: progress.thickness
|
||||
capStyle: ShapePath.RoundCap
|
||||
|
||||
PathAngleArc {
|
||||
centerX: cover.x + cover.width / 2
|
||||
centerY: cover.y + cover.height / 2
|
||||
radiusX: (cover.width + progress.thickness) / 2 + 7
|
||||
radiusY: (cover.height + progress.thickness) / 2 + 7
|
||||
startAngle: -90 - progress.angle / 2
|
||||
sweepAngle: progress.angle
|
||||
}
|
||||
|
||||
Behavior on strokeColor {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Config.colors.media
|
||||
strokeWidth: progress.thickness
|
||||
capStyle: ShapePath.RoundCap
|
||||
|
||||
PathAngleArc {
|
||||
centerX: cover.x + cover.width / 2
|
||||
centerY: cover.y + cover.height / 2
|
||||
radiusX: (cover.width + progress.thickness) / 2 + 7
|
||||
radiusY: (cover.height + progress.thickness) / 2 + 7
|
||||
startAngle: -90 - progress.angle / 2
|
||||
// NOTE: Cap progress angle to account for bad MPRIS players
|
||||
sweepAngle: progress.angle * Math.min(root.playerProgress, 1)
|
||||
}
|
||||
|
||||
Behavior on strokeColor {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: cover
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 18 + progress.thickness
|
||||
|
||||
implicitHeight: width
|
||||
color: Config.colors.inactive
|
||||
radius: Infinity
|
||||
|
||||
MaterialIcon {
|
||||
anchors.centerIn: parent
|
||||
|
||||
grade: 200
|
||||
text: "art_track"
|
||||
color: Config.colors.tertiary
|
||||
font.pointSize: (parent.width * 0.4) || 1
|
||||
}
|
||||
|
||||
Image {
|
||||
id: image
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
source: Players.active?.trackArtUrl ?? ""
|
||||
asynchronous: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: width
|
||||
sourceSize.height: height
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: title
|
||||
|
||||
anchors.top: cover.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: album.text === "" ? 24 : 18
|
||||
anchors.leftMargin: 15
|
||||
anchors.rightMargin: 15
|
||||
|
||||
animate: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title")
|
||||
color: Config.colors.secondary
|
||||
font.pointSize: Config.font.size.normal
|
||||
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: artist
|
||||
|
||||
anchors.top: title.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 15
|
||||
anchors.rightMargin: 15
|
||||
|
||||
animate: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist")
|
||||
opacity: Players.active ? 1 : 0
|
||||
color: Config.colors.primary
|
||||
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: album
|
||||
|
||||
anchors.top: artist.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 15
|
||||
anchors.rightMargin: 15
|
||||
|
||||
animate: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: Players.active?.trackAlbum ?? ""
|
||||
opacity: Players.active ? 1 : 0
|
||||
color: Config.colors.tertiary
|
||||
font.pointSize: Config.font.size.small
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
id: controls
|
||||
|
||||
anchors.top: album.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.topMargin: album.text === "" ? -4 : 2
|
||||
|
||||
spacing: 7
|
||||
|
||||
Control {
|
||||
icon: "skip_previous"
|
||||
canUse: Players.active?.canGoPrevious ?? false
|
||||
|
||||
function onClicked(): void {
|
||||
Players.active?.previous();
|
||||
}
|
||||
}
|
||||
|
||||
Control {
|
||||
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
|
||||
canUse: Players.active?.canTogglePlaying ?? false
|
||||
|
||||
function onClicked(): void {
|
||||
Players.active?.togglePlaying();
|
||||
}
|
||||
}
|
||||
|
||||
Control {
|
||||
icon: "skip_next"
|
||||
canUse: Players.active?.canGoNext ?? false
|
||||
|
||||
function onClicked(): void {
|
||||
Players.active?.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Control: CustomRect {
|
||||
id: control
|
||||
|
||||
required property string icon
|
||||
required property bool canUse
|
||||
function onClicked(): void {
|
||||
}
|
||||
|
||||
implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + 12
|
||||
implicitHeight: implicitWidth
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
disabled: !control.canUse
|
||||
radius: 1000
|
||||
|
||||
function onClicked(): void {
|
||||
control.onClicked();
|
||||
}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: icon
|
||||
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: font.pointSize * 0.05
|
||||
|
||||
animate: true
|
||||
text: control.icon
|
||||
color: control.canUse ? Config.colors.media : Config.colors.inactive
|
||||
font.pointSize: Config.font.size.large
|
||||
}
|
||||
}
|
||||
}
|
||||
264
modules/dashboard/dash/Notifs.qml
Normal file
264
modules/dashboard/dash/Notifs.qml
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.config
|
||||
import qs.custom
|
||||
import qs.services
|
||||
import qs.util
|
||||
import Quickshell
|
||||
import Quickshell.Services.Notifications
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.modules.notifications as N
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
spacing: 7
|
||||
|
||||
readonly property int notifCount:
|
||||
(Notifs.list && Notifs.list.length !== undefined) ? Notifs.list.length :
|
||||
((Notifs.list && Notifs.list.count !== undefined) ? Notifs.list.count : 0)
|
||||
|
||||
function notifAt(i) {
|
||||
if (!Notifs.list)
|
||||
return undefined;
|
||||
if (typeof Notifs.list.get === 'function')
|
||||
return Notifs.list.get(i);
|
||||
return Notifs.list[i];
|
||||
}
|
||||
|
||||
function scrollToTop(): void {
|
||||
if (notifScroll && notifScroll.contentItem && notifScroll.contentItem.contentY !== undefined) {
|
||||
notifScroll.contentItem.contentY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.margins: 10
|
||||
Layout.fillWidth: true
|
||||
spacing: 7
|
||||
|
||||
MaterialIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
id: icon
|
||||
text: {
|
||||
if (Notifs.dnd)
|
||||
return "notifications_off";
|
||||
if (notifCount > 0)
|
||||
return "notifications_active";
|
||||
return "notifications";
|
||||
}
|
||||
fill: {
|
||||
if (Notifs.dnd)
|
||||
return 0;
|
||||
if (notifCount > 0)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
color: Notifs.dnd ? Config.colors.error : Config.colors.notification
|
||||
font.pointSize: Config.font.size.larger
|
||||
animate: true
|
||||
|
||||
Behavior on color {
|
||||
SequentialAnimation {
|
||||
PauseAnimation {
|
||||
duration: icon.animateDuration / 2
|
||||
}
|
||||
PropertyAction {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
text: notifCount > 0 ? qsTr("%1 notifications").arg(notifCount) : qsTr("No notifications")
|
||||
font.weight: 600
|
||||
font.pointSize: Config.font.size.normal
|
||||
animate: true
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: qsTr("Do Not Disturb")
|
||||
}
|
||||
|
||||
CustomSwitch {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.leftMargin: 5
|
||||
Layout.rightMargin: 5
|
||||
bg: Config.colors.containerAlt
|
||||
accent: Color.mute(Config.colors.notification)
|
||||
checked: Notifs.dnd
|
||||
onToggled: Notifs.toggleDnd()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.leftMargin: 10
|
||||
Layout.rightMargin: 10
|
||||
Layout.bottomMargin: 10
|
||||
|
||||
CustomListView {
|
||||
id: notifScroll
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
anchors.rightMargin: 5
|
||||
spacing: 12
|
||||
clip: true
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
flickable: notifScroll
|
||||
}
|
||||
|
||||
model: ScriptModel {
|
||||
values: [...Notifs.list].reverse()
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: wrapper
|
||||
required property int index
|
||||
required property var modelData
|
||||
readonly property alias nonAnimHeight: notif.nonAnimHeight
|
||||
|
||||
width: 405
|
||||
height: notif.implicitHeight
|
||||
|
||||
ListView.onRemove: removeAnim.start()
|
||||
|
||||
SequentialAnimation {
|
||||
id: removeAnim
|
||||
|
||||
PropertyAction {
|
||||
target: wrapper
|
||||
property: "ListView.delayRemove"
|
||||
value: true
|
||||
}
|
||||
PropertyAction {
|
||||
target: wrapper
|
||||
property: "enabled"
|
||||
value: false
|
||||
}
|
||||
PropertyAction {
|
||||
target: wrapper
|
||||
property: "implicitHeight"
|
||||
value: 0
|
||||
}
|
||||
PropertyAction {
|
||||
target: wrapper
|
||||
property: "z"
|
||||
value: 1
|
||||
}
|
||||
Anim {
|
||||
target: notif
|
||||
property: "x"
|
||||
to: (notif.x >= 0 ? Config.notifs.width : -Config.notifs.width) * 2
|
||||
easing.bezierCurve: Config.anim.curves.emphasized
|
||||
}
|
||||
PropertyAction {
|
||||
target: wrapper
|
||||
property: "ListView.delayRemove"
|
||||
value: false
|
||||
}
|
||||
}
|
||||
|
||||
N.Notification {
|
||||
id: notif
|
||||
width: parent.width
|
||||
notif: wrapper.modelData
|
||||
color: wrapper.modelData.urgency === NotificationUrgency.Critical ? Config.colors.errorBg : Config.colors.containerAlt
|
||||
inPopup: false
|
||||
}
|
||||
}
|
||||
|
||||
add: Transition {
|
||||
Anim {
|
||||
property: "x"
|
||||
from: Config.notifs.width
|
||||
to: 0
|
||||
easing.bezierCurve: Config.anim.curves.emphasizedDecel
|
||||
}
|
||||
}
|
||||
|
||||
move: Transition {
|
||||
NotifAnim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
|
||||
displaced: Transition {
|
||||
NotifAnim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
Layout.fillWidth: true
|
||||
height: 0
|
||||
|
||||
CustomRect {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: bottomMargin
|
||||
|
||||
property real bottomMargin: notifCount > 0 ? 12 : -4
|
||||
opacity: notifCount > 0 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
implicitWidth: clearBtn.implicitWidth + 32
|
||||
implicitHeight: clearBtn.implicitHeight + 20
|
||||
|
||||
radius: 25
|
||||
color: Config.colors.inactive
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Behavior on bottomMargin {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
|
||||
function onClicked(): void {
|
||||
for (let i = root.notifCount - 1; i >= 0; i--) {
|
||||
const n = root.notifAt(i);
|
||||
n?.notification?.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: clearBtn
|
||||
|
||||
anchors.centerIn: parent
|
||||
spacing: 7
|
||||
|
||||
MaterialIcon {
|
||||
id: clearIcon
|
||||
text: "clear_all"
|
||||
color: Config.colors.secondary
|
||||
}
|
||||
|
||||
CustomText {
|
||||
text: qsTr("Clear all")
|
||||
color: Config.colors.secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component NotifAnim: Anim {
|
||||
duration: Config.anim.durations.expressiveDefaultSpatial
|
||||
easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial
|
||||
}
|
||||
}
|
||||
73
modules/dashboard/dash/User.qml
Normal file
73
modules/dashboard/dash/User.qml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import qs.config
|
||||
import qs.custom
|
||||
import qs.services
|
||||
import qs.util
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
|
||||
Item {
|
||||
id: userLine
|
||||
|
||||
implicitWidth: childrenRect.width
|
||||
implicitHeight: childrenRect.height
|
||||
|
||||
MaterialIcon {
|
||||
id: userIcon
|
||||
|
||||
anchors.left: parent.left
|
||||
|
||||
text: "account_circle"
|
||||
fill: 1
|
||||
color: Config.colors.primary
|
||||
font.pointSize: Config.font.size.larger
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: userText
|
||||
|
||||
anchors.verticalCenter: userIcon.verticalCenter
|
||||
anchors.left: userIcon.right
|
||||
anchors.leftMargin: 7
|
||||
|
||||
text: qsTr("User: %1").arg(User.user)
|
||||
color: Config.colors.secondary
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: uptimeLine
|
||||
|
||||
implicitWidth: childrenRect.width
|
||||
implicitHeight: childrenRect.height
|
||||
|
||||
MaterialIcon {
|
||||
id: uptimeIcon
|
||||
|
||||
anchors.left: parent.left
|
||||
|
||||
text: "timer"
|
||||
fill: 1
|
||||
color: Config.colors.primary
|
||||
font.pointSize: Config.font.size.larger
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: uptimeText
|
||||
|
||||
anchors.verticalCenter: uptimeIcon.verticalCenter
|
||||
anchors.left: uptimeIcon.right
|
||||
anchors.verticalCenterOffset: 1
|
||||
anchors.leftMargin: 7
|
||||
|
||||
text: qsTr("Uptime: %1").arg(User.uptime)
|
||||
color: Config.colors.secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
129
modules/dashboard/dash/Weather.qml
Normal file
129
modules/dashboard/dash/Weather.qml
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import qs.config
|
||||
import qs.custom
|
||||
import qs.services
|
||||
import qs.util
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
anchors.topMargin: 6
|
||||
|
||||
Component.onCompleted: Weather.reload()
|
||||
|
||||
CustomText {
|
||||
id: city
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
|
||||
text: Weather.city
|
||||
color: Config.colors.tertiary
|
||||
}
|
||||
|
||||
|
||||
MaterialIcon {
|
||||
id: icon
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: font.pointSize * 0.25
|
||||
|
||||
animate: true
|
||||
animateProp: "opacity"
|
||||
text: Weather.icon
|
||||
color: Weather.iconColor
|
||||
font.pointSize: Config.font.size.largest * 1.8
|
||||
|
||||
Behavior on color {
|
||||
SequentialAnimation {
|
||||
PauseAnimation {
|
||||
duration: icon.animateDuration / 2
|
||||
}
|
||||
PropertyAction {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: info
|
||||
|
||||
anchors.left: icon.right
|
||||
anchors.verticalCenter: icon.verticalCenter
|
||||
anchors.verticalCenterOffset: -3
|
||||
anchors.leftMargin: 14
|
||||
spacing: 1
|
||||
|
||||
opacity: Weather.available ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
SequentialAnimation {
|
||||
PauseAnimation {
|
||||
duration: temp.animateDuration / 2
|
||||
}
|
||||
PropertyAction {}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: temp
|
||||
animate: true
|
||||
text: Weather.temp
|
||||
color: Config.colors.primary
|
||||
font.pointSize: Config.font.size.large
|
||||
font.weight: 500
|
||||
|
||||
// Reduce padding at bottom of text
|
||||
height: implicitHeight * 0.9
|
||||
|
||||
CustomText {
|
||||
anchors.left: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 12
|
||||
anchors.bottomMargin: 1
|
||||
|
||||
animate: true
|
||||
text: Weather.feelsLike
|
||||
color: Config.colors.tertiary
|
||||
font.pointSize: Config.font.size.larger
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
animate: true
|
||||
text: Weather.description
|
||||
color: Config.colors.secondary
|
||||
|
||||
elide: Text.ElideRight
|
||||
width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - 30)
|
||||
}
|
||||
|
||||
Item {
|
||||
implicitWidth: childrenRect.width
|
||||
implicitHeight: childrenRect.height
|
||||
|
||||
MaterialIcon {
|
||||
id: humidityIcon
|
||||
|
||||
animate: true
|
||||
text: Weather.humidityIcon
|
||||
color: Config.colors.primary
|
||||
font.pointSize: Config.font.size.normal
|
||||
|
||||
}
|
||||
|
||||
CustomText {
|
||||
anchors.left: humidityIcon.right
|
||||
anchors.verticalCenter: humidityIcon.verticalCenter
|
||||
anchors.leftMargin: 2
|
||||
|
||||
animate: true
|
||||
text: `${Math.round(Weather.humidity * 100)}% Humidity`
|
||||
color: Config.colors.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue