Add desktop configuration
This commit is contained in:
1 parent
afda67ea6c
commit
c5611d9129
73 files changed
+137647
No files matched your search
@@ -0,0 +1,548 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Services.SystemTray
|
||||
import Quickshell.Services.Pipewire
|
||||
import Quickshell.Services.UPower
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
// ───────────── SETTINGS & THEMING ─────────────
|
||||
readonly property string mono: "JetBrainsMono Nerd Font"
|
||||
readonly property int barWidth: 42
|
||||
readonly property int wsCount: 5
|
||||
|
||||
readonly property color cBar: "#0a0a0a"
|
||||
readonly property color cPanel: "#0d0d0d"
|
||||
readonly property color cBorder: "#333333"
|
||||
readonly property color cText: "#c8c8c8"
|
||||
readonly property color cDim: "#555555"
|
||||
readonly property color cBright: "#ffffff"
|
||||
readonly property color cAccent: "#ffffff"
|
||||
readonly property color cWarn: "#e05555"
|
||||
|
||||
// ───────────── STATE ─────────────
|
||||
property bool launcherOpen: false
|
||||
property bool netUp: false
|
||||
property bool btOn: false
|
||||
property string activeApplet: ""
|
||||
|
||||
PwObjectTracker { objects: [Pipewire] }
|
||||
|
||||
IpcHandler {
|
||||
target: "launcher"
|
||||
function toggle(): void { root.launcherOpen = !root.launcherOpen }
|
||||
function open(): void { root.launcherOpen = true }
|
||||
function close(): void { root.launcherOpen = false }
|
||||
}
|
||||
|
||||
SystemClock { id: clock; precision: SystemClock.Minutes }
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: ["sh", "-c",
|
||||
"nmcli -t -f STATE general 2>/dev/null | grep -q '^connected' && echo net=1 || echo net=0; " +
|
||||
"nmcli -t -f WIRELESS radio 2>/dev/null | grep -q 'enabled' && echo bt=1 || echo bt=0"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.netUp = text.includes("net=1")
|
||||
root.btOn = text.includes("bt=1")
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer { interval: 5000; running: true; repeat: true; triggeredOnStart: true; onTriggered: statusProc.running = true }
|
||||
|
||||
// ───────────── VERTICAL BAR ─────────────
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
id: bar
|
||||
required property var modelData
|
||||
screen: modelData
|
||||
|
||||
anchors { top: true; bottom: true; left: true }
|
||||
implicitWidth: root.barWidth
|
||||
color: root.cBar
|
||||
|
||||
Rectangle {
|
||||
anchors { top: parent.top; bottom: parent.bottom; right: parent.right }
|
||||
width: 1
|
||||
color: root.cBorder
|
||||
}
|
||||
|
||||
// Launcher Button & Workspaces (Top)
|
||||
ColumnLayout {
|
||||
anchors { top: parent.top; topMargin: 12; horizontalCenter: parent.horizontalCenter }
|
||||
spacing: 6
|
||||
|
||||
// App Launcher Icon
|
||||
Item {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
implicitWidth: 32; implicitHeight: 32
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
font.family: root.mono; font.pixelSize: 14
|
||||
color: root.launcherOpen ? root.cBright : root.cDim
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.launcherOpen = !root.launcherOpen
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder; Layout.bottomMargin: 4 }
|
||||
|
||||
Repeater {
|
||||
model: root.wsCount
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
readonly property int wsId: index + 1
|
||||
readonly property bool active: Hyprland.focusedWorkspace?.id === wsId
|
||||
readonly property bool occupied: Hyprland.workspaces.values.some(w => w.id === wsId)
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
implicitWidth: 32; implicitHeight: 32
|
||||
color: "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: parent.wsId
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
font.bold: parent.active
|
||||
color: parent.active ? root.cBright : (parent.occupied ? root.cText : root.cDim)
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: Quickshell.execDetached(["hyprctl", "dispatch", "workspace " + parent.wsId])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clock & Applet Triggers (Bottom) - NOW WITH BLUETOOTH & NETWORK INDICATORS
|
||||
ColumnLayout {
|
||||
anchors { bottom: parent.bottom; bottomMargin: 12; horizontalCenter: parent.horizontalCenter }
|
||||
spacing: 12
|
||||
|
||||
Text { Layout.alignment: Qt.AlignHCenter; text: Qt.formatDateTime(clock.date, "HH"); font.family: root.mono; font.pixelSize: 12; font.bold: true; color: root.cBright }
|
||||
Text { Layout.alignment: Qt.AlignHCenter; text: Qt.formatDateTime(clock.date, "mm"); font.family: root.mono; font.pixelSize: 12; color: root.cDim; Layout.topMargin: -8 }
|
||||
|
||||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder; Layout.topMargin: 4; Layout.bottomMargin: 4 }
|
||||
|
||||
// NETWORK INDICATOR (ADDED)
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: root.netUp ? "" : "" // Wi-Fi when connected, Ethernet when not (or vice versa based on preference)
|
||||
font.family: root.mono; font.pixelSize: 14
|
||||
color: root.netUp ? root.cAccent : root.cDim
|
||||
MouseArea {
|
||||
anchors.fill: parent;
|
||||
anchors.margins: -8;
|
||||
onClicked: root.activeApplet = root.activeApplet === "wifi" ? "" : "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder; Layout.topMargin: 4; Layout.bottomMargin: 4 }
|
||||
|
||||
// BLUETOOTH INDICATOR (ADDED)
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: root.btOn ? "" : "" // Bluetooth on/off
|
||||
font.family: root.mono; font.pixelSize: 14
|
||||
color: root.btOn ? root.cAccent : root.cDim
|
||||
MouseArea {
|
||||
anchors.fill: parent;
|
||||
anchors.margins: -8;
|
||||
onClicked: root.activeApplet = root.activeApplet === "bluetooth" ? "" : "bluetooth"
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder; Layout.topMargin: 4; Layout.bottomMargin: 4 }
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: Pipewire.defaultAudioSink?.audio?.muted ? "" : ""
|
||||
font.family: root.mono; font.pixelSize: 14
|
||||
color: root.activeApplet === "sound" ? root.cAccent : root.cText
|
||||
MouseArea { anchors.fill: parent; anchors.margins: -8; onClicked: root.activeApplet = root.activeApplet === "sound" ? "" : "sound" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────── APP LAUNCHER OVERLAY ─────────────
|
||||
PanelWindow {
|
||||
id: launcherWindow
|
||||
visible: root.launcherOpen
|
||||
|
||||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||||
|
||||
// Centered overlay matching the design in screenshots
|
||||
implicitWidth: 320
|
||||
implicitHeight: 520
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "launcher"
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.cPanel
|
||||
border.color: root.cBorder
|
||||
border.width: 1
|
||||
|
||||
FocusScope {
|
||||
id: launcherScope
|
||||
anchors.fill: parent
|
||||
focus: launcherWindow.visible
|
||||
|
||||
Keys.onEscapePressed: root.launcherOpen = false
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 10
|
||||
|
||||
// Search Input Box ("Apps...")
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 34
|
||||
color: "#141414"
|
||||
border.color: searchInput.activeFocus ? root.cBright : root.cBorder
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: root.cDim
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: searchInput
|
||||
Layout.fillWidth: true
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: root.cBright
|
||||
focus: true
|
||||
selectByMouse: true
|
||||
|
||||
Text {
|
||||
text: "Apps..."
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: root.cDim
|
||||
visible: searchInput.text === ""
|
||||
}
|
||||
|
||||
Keys.onDownPressed: appListView.incrementCurrentIndex()
|
||||
Keys.onUpPressed: appListView.decrementCurrentIndex()
|
||||
Keys.onReturnPressed: {
|
||||
if (appListView.currentItem) {
|
||||
appListView.currentItem.launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Application List
|
||||
ListView {
|
||||
id: appListView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
spacing: 2
|
||||
currentIndex: 0
|
||||
|
||||
model: appListProc.apps
|
||||
|
||||
delegate: Rectangle {
|
||||
id: appItem
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: appListView.width
|
||||
height: 36
|
||||
color: ListView.isCurrentItem ? "#1a1a1a" : (itemArea.containsMouse ? "#141414" : "transparent")
|
||||
border.color: ListView.isCurrentItem ? root.cBorder : "transparent"
|
||||
|
||||
function launch() {
|
||||
Quickshell.execDetached(["sh", "-c", modelData.exec])
|
||||
root.launcherOpen = false
|
||||
searchInput.text = ""
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
font.family: root.mono
|
||||
font.pixelSize: 13
|
||||
color: ListView.isCurrentItem ? root.cBright : root.cDim
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.name
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: ListView.isCurrentItem ? root.cBright : root.cText
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: appItem.launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process to fetch installed desktop applications
|
||||
Process {
|
||||
id: appListProc
|
||||
property var rawApps: []
|
||||
property var apps: {
|
||||
const query = searchInput.text.toLowerCase().trim()
|
||||
if (!query) return rawApps
|
||||
return rawApps.filter(a => a.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
command: ["python3", "-c", `
|
||||
import os, glob
|
||||
|
||||
apps = []
|
||||
dirs = ['/usr/share/applications', os.path.expanduser('~/.local/share/applications')]
|
||||
for d in dirs:
|
||||
for path in glob.glob(os.path.join(d, '*.desktop')):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
name, exec_cmd, nodisplay = "", "", False
|
||||
for line in f:
|
||||
if line.startswith('Name=') and not name:
|
||||
name = line.split('=', 1)[1].strip()
|
||||
elif line.startswith('Exec=') and not exec_cmd:
|
||||
exec_cmd = line.split('=', 1)[1].strip().split('%')[0].strip()
|
||||
elif line.startswith('NoDisplay=true'):
|
||||
nodisplay = True
|
||||
if name and exec_cmd and not nodisplay:
|
||||
apps.append(f"{name}|||{exec_cmd}")
|
||||
except:
|
||||
pass
|
||||
|
||||
apps = sorted(list(set(apps)), key=lambda x: x.lower())
|
||||
for a in apps:
|
||||
print(a)
|
||||
`]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split("\n")
|
||||
let parsed = []
|
||||
for (let line of lines) {
|
||||
const parts = line.split("|||")
|
||||
if (parts.length === 2) {
|
||||
parsed.push({ name: parts[0], exec: parts[1] })
|
||||
}
|
||||
}
|
||||
appListProc.rawApps = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: appListProc.running = true
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
searchInput.text = ""
|
||||
searchInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────── APPLETS POPUP WINDOW ─────────────
|
||||
PanelWindow {
|
||||
id: appletWindow
|
||||
visible: root.activeApplet !== ""
|
||||
|
||||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||||
anchors { left: true; bottom: true }
|
||||
|
||||
margins.left: root.barWidth + 12
|
||||
margins.bottom: 12
|
||||
|
||||
implicitWidth: 340
|
||||
implicitHeight: root.activeApplet === "sound" ? 420 : 380
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "applet"
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.cPanel
|
||||
border.color: root.cBorder
|
||||
border.width: 1
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: appletWindow.visible
|
||||
Keys.onEscapePressed: root.activeApplet = ""
|
||||
}
|
||||
|
||||
// ─ NETWORK APPLET ─
|
||||
ColumnLayout {
|
||||
anchors.fill: parent; anchors.margins: 16
|
||||
visible: root.activeApplet === "wifi"
|
||||
spacing: 16
|
||||
|
||||
property var wifiList: []
|
||||
|
||||
Process {
|
||||
id: wifiScanProc
|
||||
command: ["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split("\n")
|
||||
let results = []
|
||||
for (let l of lines) {
|
||||
if (!l) continue
|
||||
const parts = l.split(":")
|
||||
if (parts[0] && !results.some(r => r.ssid === parts[0])) {
|
||||
results.push({ ssid: parts[0], signal: parseInt(parts[1]), active: parts[3] === "*" })
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.active - a.active || b.signal - a.signal)
|
||||
parent.wifiList = results
|
||||
}
|
||||
}
|
||||
}
|
||||
onVisibleChanged: if(visible) wifiScanProc.running = true
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
Text { text: "Network"; font.family: root.mono; font.pixelSize: 14; font.bold: true; color: root.cBright }
|
||||
Text { text: "WI-FI & ETHERNET"; font.family: root.mono; font.pixelSize: 10; color: root.cDim }
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Rectangle {
|
||||
width: 32; height: 18; color: root.netUp ? root.cBright : root.cBorder
|
||||
Rectangle { x: root.netUp ? 15 : 1; y: 1; width: 16; height: 16; color: root.cPanel; Behavior on x { NumberAnimation { duration: 150 } } }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder }
|
||||
|
||||
Text { text: "ZNANE SIECI"; font.family: root.mono; font.pixelSize: 10; color: root.cDim }
|
||||
|
||||
ListView {
|
||||
Layout.fillWidth: true; Layout.fillHeight: true; clip: true; spacing: 8
|
||||
model: parent.wifiList
|
||||
delegate: Rectangle {
|
||||
width: ListView.view.width; height: 32; color: "transparent"
|
||||
border.color: modelData.active ? root.cBorder : "transparent"
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent; anchors.leftMargin: 12; anchors.rightMargin: 12; spacing: 12
|
||||
Text { text: ""; font.family: root.mono; font.pixelSize: 12; color: modelData.active ? root.cAccent : root.cDim }
|
||||
Text { text: modelData.ssid; font.family: root.mono; font.pixelSize: 12; color: modelData.active ? root.cBright : root.cText; Layout.fillWidth: true; elide: Text.ElideRight }
|
||||
}
|
||||
MouseArea { anchors.fill: parent; onClicked: { Quickshell.execDetached(["foot", "-e", "nmtui-connect", modelData.ssid]); root.activeApplet = "" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─ AUDIO APPLET ─
|
||||
ColumnLayout {
|
||||
anchors.fill: parent; anchors.margins: 16
|
||||
visible: root.activeApplet === "sound"
|
||||
spacing: 12
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
Text { text: "Audio"; font.family: root.mono; font.pixelSize: 14; font.bold: true; color: root.cBright }
|
||||
Text { text: "POKRYJ HAŁAS"; font.family: root.mono; font.pixelSize: 10; color: root.cDim }
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Rectangle {
|
||||
width: 32; height: 18; color: !Pipewire.defaultAudioSink?.audio?.muted ? root.cBright : root.cBorder
|
||||
Rectangle { x: !Pipewire.defaultAudioSink?.audio?.muted ? 15 : 1; y: 1; width: 16; height: 16; color: root.cPanel; Behavior on x { NumberAnimation { duration: 150 } } }
|
||||
MouseArea { anchors.fill: parent; onClicked: if(Pipewire.defaultAudioSink?.audio) Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder; Layout.bottomMargin: 8 }
|
||||
|
||||
// Input Section
|
||||
Text { text: "INPUT"; font.family: root.mono; font.pixelSize: 10; color: root.cDim }
|
||||
Repeater {
|
||||
model: Pipewire.nodes.values.filter(n => n.mediaClass === "Audio/Source" && !n.description.includes("Monitor"))
|
||||
delegate: ColumnLayout {
|
||||
Layout.fillWidth: true; spacing: 4
|
||||
RowLayout {
|
||||
Text { text: ""; font.family: root.mono; font.pixelSize: 12; color: root.cDim }
|
||||
Text { text: modelData.description; font.family: root.mono; font.pixelSize: 11; color: root.cText; Layout.fillWidth: true; elide: Text.ElideRight }
|
||||
}
|
||||
Slider {
|
||||
Layout.fillWidth: true; from: 0; to: 1; value: modelData.audio?.volume ?? 0
|
||||
onValueChanged: if (modelData.audio && Math.abs(modelData.audio.volume - value) > 0.01) modelData.audio.volume = value
|
||||
background: Rectangle { width: parent.width; height: 2; color: root.cBorder; y: parent.height/2; Rectangle { width: parent.visualPosition * parent.width; height: 2; color: root.cBright } }
|
||||
handle: Rectangle { x: parent.visualPosition * (parent.width - width); y: parent.height/2 - height/2; width: 10; height: 10; color: root.cBright }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 8 }
|
||||
|
||||
// Output Section
|
||||
Text { text: "OUTPUT"; font.family: root.mono; font.pixelSize: 10; color: root.cDim }
|
||||
Repeater {
|
||||
model: Pipewire.nodes.values.filter(n => n.mediaClass === "Audio/Sink")
|
||||
delegate: ColumnLayout {
|
||||
Layout.fillWidth: true; spacing: 4
|
||||
RowLayout {
|
||||
Text { text: ""; font.family: root.mono; font.pixelSize: 12; color: root.cDim }
|
||||
Text { text: modelData.description; font.family: root.mono; font.pixelSize: 11; color: root.cText; Layout.fillWidth: true; elide: Text.ElideRight }
|
||||
}
|
||||
Slider {
|
||||
Layout.fillWidth: true; from: 0; to: 1; value: modelData.audio?.volume ?? 0
|
||||
onValueChanged: if (modelData.audio && Math.abs(modelData.audio.volume - value) > 0.01) modelData.audio.volume = value
|
||||
background: Rectangle { width: parent.width; height: 2; color: root.cBorder; y: parent.height/2; Rectangle { width: parent.visualPosition * parent.width; height: 2; color: root.cBright } }
|
||||
handle: Rectangle { x: parent.visualPosition * (parent.width - width); y: parent.height/2 - height/2; width: 10; height: 10; color: root.cBright }
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user