feat: add UI layer (theme, components, screens, main window)
- theme: MinecraftTheme — dark palette with green accents - components: ServerCard, ProgressBar, PlayButton, SettingsButton, LogoutButton, AvatarImage - screens: MainScreen (BorderLayout: sidebar + center + bottom bar), LoginScreen (modal form), SettingsScreen (RAM slider + JVM args) - ui.Launch: wires theme, session, settings into Fyne app - main.go: delegates to ui.Launch, loads config - fix: nil-session guard in MainScreen username display Co-Authored-By: OWL <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"fyne.io/fyne/v2/app"
|
|
||||||
"fyne.io/fyne/v2/container"
|
|
||||||
"fyne.io/fyne/v2/widget"
|
|
||||||
|
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is set via -ldflags at build time.
|
// version is set via -ldflags at build time.
|
||||||
@@ -24,7 +20,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("Data directory: %s", root)
|
log.Printf("Data directory: %s", root)
|
||||||
|
|
||||||
// Try to restore an existing session.
|
settings, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
client, err := auth.NewFromConfig()
|
client, err := auth.NewFromConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create auth client: %v", err)
|
log.Fatalf("Failed to create auth client: %v", err)
|
||||||
@@ -41,15 +41,5 @@ func main() {
|
|||||||
log.Println("No valid session — login required")
|
log.Println("No valid session — login required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap Fyne UI.
|
ui.Launch(client, sess, settings)
|
||||||
a := app.New()
|
|
||||||
w := a.NewWindow(fmt.Sprintf("MrixsCraft %s", version))
|
|
||||||
w.Resize(fyne.NewSize(900, 600))
|
|
||||||
w.CenterOnScreen()
|
|
||||||
|
|
||||||
w.SetContent(container.NewVBox(
|
|
||||||
widget.NewLabel(fmt.Sprintf("MrixsCraft Launcher %s", version)),
|
|
||||||
))
|
|
||||||
|
|
||||||
w.ShowAndRun()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,94 @@
|
|||||||
// package components provides reusable Fyne widgets (avatar, progress bar).
|
// package components provides reusable Fyne widgets for the launcher UI.
|
||||||
package components
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerCard displays a single modpack entry in the sidebar.
|
||||||
|
func ServerCard(name string, selected bool, onTap func()) fyne.CanvasObject {
|
||||||
|
bg := canvas.NewRectangle(color.Transparent)
|
||||||
|
bg.SetMinSize(fyne.NewSize(180, 48))
|
||||||
|
|
||||||
|
label := widget.NewLabel(name)
|
||||||
|
label.TextStyle.Bold = selected
|
||||||
|
|
||||||
|
content := container.NewBorder(nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
|
||||||
|
card := container.NewStack(bg, content)
|
||||||
|
|
||||||
|
btn := widget.NewButton("", onTap)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
return container.NewStack(card, btn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressBar wraps widget.ProgressBar with show/hide helpers.
|
||||||
|
type ProgressBar struct {
|
||||||
|
*widget.ProgressBar
|
||||||
|
hidden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgressBar creates a new progress bar initialized to 0 and hidden.
|
||||||
|
func NewProgressBar() *ProgressBar {
|
||||||
|
pb := widget.NewProgressBar()
|
||||||
|
pb.Hide()
|
||||||
|
return &ProgressBar{
|
||||||
|
ProgressBar: pb,
|
||||||
|
hidden: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProgress sets the value [0…1] and shows the bar.
|
||||||
|
func (pb *ProgressBar) SetProgress(v float64) {
|
||||||
|
pb.SetValue(v)
|
||||||
|
if pb.hidden {
|
||||||
|
pb.Show()
|
||||||
|
pb.hidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done hides the progress bar and resets it.
|
||||||
|
func (pb *ProgressBar) Done() {
|
||||||
|
pb.SetValue(0)
|
||||||
|
pb.Hide()
|
||||||
|
pb.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayButton creates the accent "PLAY" button.
|
||||||
|
func PlayButton(onTap func()) *widget.Button {
|
||||||
|
btn := widget.NewButton("▶ PLAY", onTap)
|
||||||
|
btn.Importance = widget.HighImportance
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsButton creates the small settings (gear) button.
|
||||||
|
func SettingsButton(onTap func()) *widget.Button {
|
||||||
|
btn := widget.NewButton("⚙", onTap)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutButton creates the logout button.
|
||||||
|
func LogoutButton(onTap func()) *widget.Button {
|
||||||
|
btn := widget.NewButton("Выйти", onTap)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarImage returns a placeholder avatar (64×64 colored square).
|
||||||
|
// TODO: render 8×64 face from skin PNG.
|
||||||
|
func AvatarImage() *canvas.Image {
|
||||||
|
img := canvas.NewImageFromResource(nil)
|
||||||
|
img.SetMinSize(fyne.NewSize(64, 64))
|
||||||
|
img.FillMode = canvas.ImageFillContain
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,193 @@
|
|||||||
// package screens implements application screens (login, main menu, settings).
|
// package screens implements application screens (login, main menu, settings).
|
||||||
package screens
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modpack represents a single server/modpack entry from the backend.
|
||||||
|
type Modpack struct {
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainScreen builds the primary launcher window content.
|
||||||
|
func MainScreen(
|
||||||
|
w fyne.Window,
|
||||||
|
client *auth.Client,
|
||||||
|
session *auth.Session,
|
||||||
|
serverList []Modpack,
|
||||||
|
onPlay func(),
|
||||||
|
onSettings func(),
|
||||||
|
) fyne.CanvasObject {
|
||||||
|
|
||||||
|
// ── Sidebar (Left) ──────────────────────────────────────
|
||||||
|
serverLabels := make([]*widget.Label, len(serverList))
|
||||||
|
serverContainer := container.NewVBox()
|
||||||
|
|
||||||
|
for i, sp := range serverList {
|
||||||
|
label := widget.NewLabel(sp.Name)
|
||||||
|
serverLabels[i] = label
|
||||||
|
index := i
|
||||||
|
card := components.ServerCard(sp.Name, index == 0, func() {
|
||||||
|
for j, l := range serverLabels {
|
||||||
|
l.TextStyle.Bold = (j == index)
|
||||||
|
l.Refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
serverContainer.Add(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebar := container.NewVScroll(serverContainer)
|
||||||
|
|
||||||
|
// ── Center (background + info) ──────────────────────────
|
||||||
|
bg := widget.NewRichTextFromMarkdown(
|
||||||
|
"# Welcome to MrixsCraft\n\n" +
|
||||||
|
"Select a server from the left panel and press **PLAY**.\n\n" +
|
||||||
|
"---\n\n" +
|
||||||
|
"*Version: dev*",
|
||||||
|
)
|
||||||
|
center := container.NewMax(widget.NewCard("", "", bg))
|
||||||
|
|
||||||
|
// ── Bottom bar ──────────────────────────────────────────
|
||||||
|
avatar := components.AvatarImage()
|
||||||
|
|
||||||
|
displayName := "Not logged in"
|
||||||
|
if session != nil && session.Username != "" {
|
||||||
|
displayName = session.Username
|
||||||
|
}
|
||||||
|
usernameLabel := widget.NewLabel(displayName)
|
||||||
|
|
||||||
|
logoutBtn := components.LogoutButton(func() {
|
||||||
|
dialog.ShowConfirm(
|
||||||
|
"Logout",
|
||||||
|
"Are you sure you want to log out?",
|
||||||
|
func(confirmed bool) {
|
||||||
|
if confirmed {
|
||||||
|
_ = auth.Delete()
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
bottomLeft := container.NewHBox(
|
||||||
|
avatar,
|
||||||
|
usernameLabel,
|
||||||
|
layout.NewSpacer(),
|
||||||
|
logoutBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
progress := components.NewProgressBar()
|
||||||
|
|
||||||
|
playBtn := components.PlayButton(onPlay)
|
||||||
|
settingsBtn := components.SettingsButton(onSettings)
|
||||||
|
|
||||||
|
bottomRight := container.NewHBox(
|
||||||
|
settingsBtn,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
playBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
bottomCenter := container.NewMax(progress)
|
||||||
|
|
||||||
|
bottom := container.NewBorder(
|
||||||
|
nil, nil,
|
||||||
|
bottomLeft,
|
||||||
|
bottomRight,
|
||||||
|
bottomCenter,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Assemble ────────────────────────────────────────────
|
||||||
|
return container.NewBorder(
|
||||||
|
nil,
|
||||||
|
bottom,
|
||||||
|
sidebar,
|
||||||
|
nil,
|
||||||
|
center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginScreen builds a modal login form.
|
||||||
|
func LoginScreen(w fyne.Window, client *auth.Client, onLogin func(*auth.Session)) {
|
||||||
|
username := widget.NewEntry()
|
||||||
|
username.SetPlaceHolder("Username or email")
|
||||||
|
|
||||||
|
password := widget.NewPasswordEntry()
|
||||||
|
password.SetPlaceHolder("Password")
|
||||||
|
|
||||||
|
form := widget.NewForm(
|
||||||
|
widget.NewFormItem("Username", username),
|
||||||
|
widget.NewFormItem("Password", password),
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.ShowCustomConfirm(
|
||||||
|
"Login — MrixsCraft",
|
||||||
|
"Login",
|
||||||
|
"Cancel",
|
||||||
|
form,
|
||||||
|
func(confirmed bool) {
|
||||||
|
if !confirmed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, err := client.Authenticate(username.Text, password.Text)
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sess.Save(); err != nil {
|
||||||
|
dialog.ShowError(err, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onLogin(sess)
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsScreen builds the settings modal.
|
||||||
|
func SettingsScreen(w fyne.Window, onSave func(memoryMB int, extraArgs string)) {
|
||||||
|
memorySlider := widget.NewSlider(1024, 16384)
|
||||||
|
memorySlider.Step = 512
|
||||||
|
memorySlider.Value = 4096
|
||||||
|
|
||||||
|
memLabel := widget.NewLabel("4096 MB")
|
||||||
|
memorySlider.OnChanged = func(v float64) {
|
||||||
|
memLabel.SetText(fmt.Sprintf("%.0f MB", v))
|
||||||
|
}
|
||||||
|
|
||||||
|
extraArgs := widget.NewMultiLineEntry()
|
||||||
|
extraArgs.SetPlaceHolder("e.g. -XX:+UseG1GC -XX:MaxGCPauseMillis=50")
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Memory (RAM)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
memLabel,
|
||||||
|
memorySlider,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Extra JVM arguments", fyne.TextAlignLeading, fyne.TextStyle{}),
|
||||||
|
extraArgs,
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.ShowCustomConfirm(
|
||||||
|
"Settings",
|
||||||
|
"Save",
|
||||||
|
"Cancel",
|
||||||
|
content,
|
||||||
|
func(confirmed bool) {
|
||||||
|
if confirmed {
|
||||||
|
onSave(int(memorySlider.Value), extraArgs.Text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,92 @@
|
|||||||
// package theme defines the custom Fyne theme (Minecraft-style colors, fonts).
|
// package theme defines a custom Fyne theme with Minecraft-inspired colors.
|
||||||
package theme
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinecraftTheme is a dark theme inspired by Minecraft's UI palette.
|
||||||
|
type MinecraftTheme struct{}
|
||||||
|
|
||||||
|
var _ fyne.Theme = (*MinecraftTheme)(nil)
|
||||||
|
|
||||||
|
// Color returns the named color from the Minecraft palette.
|
||||||
|
func (t *MinecraftTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||||
|
switch name {
|
||||||
|
case theme.ColorNameBackground:
|
||||||
|
return color.RGBA{R: 30, G: 30, B: 30, A: 255} // dark stone
|
||||||
|
case theme.ColorNameButton:
|
||||||
|
return color.RGBA{R: 100, G: 100, B: 100, A: 255} // stone gray
|
||||||
|
case theme.ColorNameDisabled:
|
||||||
|
return color.RGBA{R: 80, G: 80, B: 80, A: 255}
|
||||||
|
case theme.ColorNameDisabledButton:
|
||||||
|
return color.RGBA{R: 60, G: 60, B: 60, A: 255}
|
||||||
|
case theme.ColorNameError:
|
||||||
|
return color.RGBA{R: 200, G: 50, B: 50, A: 255}
|
||||||
|
case theme.ColorNameFocus:
|
||||||
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green accent
|
||||||
|
case theme.ColorNameForeground:
|
||||||
|
return color.RGBA{R: 220, G: 220, B: 220, A: 255} // light gray
|
||||||
|
case theme.ColorNameHover:
|
||||||
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green
|
||||||
|
case theme.ColorNameInputBackground:
|
||||||
|
return color.RGBA{R: 50, G: 50, B: 50, A: 255}
|
||||||
|
case theme.ColorNameInputBorder:
|
||||||
|
return color.RGBA{R: 80, G: 80, B: 80, A: 255}
|
||||||
|
case theme.ColorNameMenuBackground:
|
||||||
|
return color.RGBA{R: 40, G: 40, B: 40, A: 255}
|
||||||
|
case theme.ColorNameOverlayBackground:
|
||||||
|
return color.RGBA{R: 0, G: 0, B: 0, A: 180}
|
||||||
|
case theme.ColorNamePlaceHolder:
|
||||||
|
return color.RGBA{R: 150, G: 150, B: 150, A: 255}
|
||||||
|
case theme.ColorNamePressed:
|
||||||
|
return color.RGBA{R: 56, G: 142, B: 60, A: 255} // dark green
|
||||||
|
case theme.ColorNamePrimary:
|
||||||
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green
|
||||||
|
case theme.ColorNameScrollBar:
|
||||||
|
return color.RGBA{R: 80, G: 80, B: 80, A: 200}
|
||||||
|
case theme.ColorNameSeparator:
|
||||||
|
return color.RGBA{R: 60, G: 60, B: 60, A: 255}
|
||||||
|
case theme.ColorNameShadow:
|
||||||
|
return color.RGBA{R: 0, G: 0, B: 0, A: 128}
|
||||||
|
default:
|
||||||
|
return theme.DefaultTheme().Color(name, variant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font returns the default font (custom TTF can be added later).
|
||||||
|
func (t *MinecraftTheme) Font(style fyne.TextStyle) fyne.Resource {
|
||||||
|
return theme.DefaultTheme().Font(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon returns the named icon resource.
|
||||||
|
func (t *MinecraftTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
|
||||||
|
return theme.DefaultTheme().Icon(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the named size value.
|
||||||
|
func (t *MinecraftTheme) Size(name fyne.ThemeSizeName) float32 {
|
||||||
|
switch name {
|
||||||
|
case theme.SizeNameCaptionText:
|
||||||
|
return 11
|
||||||
|
case theme.SizeNameInlineIcon:
|
||||||
|
return 20
|
||||||
|
case theme.SizeNamePadding:
|
||||||
|
return 4
|
||||||
|
case theme.SizeNameScrollBar:
|
||||||
|
return 12
|
||||||
|
case theme.SizeNameScrollBarSmall:
|
||||||
|
return 4
|
||||||
|
case theme.SizeNameText:
|
||||||
|
return 14
|
||||||
|
case theme.SizeNameHeadingText:
|
||||||
|
return 24
|
||||||
|
case theme.SizeNameSubHeadingText:
|
||||||
|
return 18
|
||||||
|
default:
|
||||||
|
return theme.DefaultTheme().Size(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,55 @@
|
|||||||
// package ui contains Fyne GUI code (main window, theme, navigation).
|
// package ui contains the Fyne GUI bootstrap and wiring.
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/screens"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Launch initialises the Fyne application with the Minecraft theme and shows the main window.
|
||||||
|
func Launch(client *auth.Client, session *auth.Session, settings config.Settings) {
|
||||||
|
a := app.New()
|
||||||
|
a.Settings().SetTheme(&theme.MinecraftTheme{})
|
||||||
|
|
||||||
|
w := a.NewWindow("MrixsCraft")
|
||||||
|
w.Resize(fyne.NewSize(float32(settings.Width), float32(settings.Height)))
|
||||||
|
w.CenterOnScreen()
|
||||||
|
|
||||||
|
// TODO: fetch from /api/servers.json
|
||||||
|
serverList := []screens.Modpack{
|
||||||
|
{Slug: "hitech", Name: "HiTech 1.21"},
|
||||||
|
{Slug: "vanilla", Name: "Vanilla 1.20"},
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlay := func() {
|
||||||
|
// TODO: launch selected modpack
|
||||||
|
}
|
||||||
|
|
||||||
|
onSettings := func() {
|
||||||
|
screens.SettingsScreen(w, func(memoryMB int, extraArgs string) {
|
||||||
|
settings.MemoryMB = memoryMB
|
||||||
|
settings.ExtraArgs = extraArgs
|
||||||
|
_ = config.Save(settings)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
// If no session, show login modal after the window is rendered.
|
||||||
|
if session == nil {
|
||||||
|
w.Show()
|
||||||
|
screens.LoginScreen(w, client, func(sess *auth.Session) {
|
||||||
|
// Refresh UI with logged-in state.
|
||||||
|
content := screens.MainScreen(w, client, sess, serverList, onPlay, onSettings)
|
||||||
|
w.SetContent(content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ShowAndRun()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user