Add settings page & more
This commit is contained in:
16 files changed
+361
-41
No files matched your search
@@ -12,9 +12,12 @@
|
||||
"@fortawesome/react-fontawesome": "^3.3.0",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -268,12 +271,18 @@
|
||||
|
||||
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
|
||||
|
||||
"@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.5", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA=="],
|
||||
|
||||
"@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.7", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A=="],
|
||||
|
||||
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
||||
|
||||
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
|
||||
|
||||
"@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
"@fortawesome/react-fontawesome": "^3.3.0",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
||||
@@ -20,6 +20,9 @@ tauri-plugin-http = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-os = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
@@ -43,6 +45,9 @@
|
||||
"url": "https://games.lncvrt.xyz/api/berrydash/leaderboard/total"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"store:default",
|
||||
"fs:default",
|
||||
"os:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
|
||||
+36
-19
@@ -2,7 +2,6 @@
|
||||
|
||||
import '@/styles/globals.css'
|
||||
import { Roboto } from 'next/font/google'
|
||||
import LauncherContext from '@/context/LauncherContext'
|
||||
|
||||
import Sidebar from '@/componets/Sidebar'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -13,12 +12,43 @@ import {
|
||||
isPermissionGranted,
|
||||
requestPermission
|
||||
} from '@tauri-apps/plugin-notification'
|
||||
import GlobalProvider from '@/providers/GlobalProvider'
|
||||
import { SettingsProvider, useSettings } from '@/providers/SettingsProvider'
|
||||
|
||||
const roboto = Roboto({
|
||||
variable: '--font-roboto',
|
||||
subsets: ['latin']
|
||||
})
|
||||
|
||||
function RootLayoutContent ({
|
||||
children,
|
||||
loading
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
loading: 0 | 1 | 2 | 3
|
||||
}) {
|
||||
const { theme } = useSettings()
|
||||
|
||||
return (
|
||||
<body className={theme + '-theme'}>
|
||||
{loading == 3 ? (
|
||||
<>
|
||||
<Sidebar />
|
||||
<main className='ml-60 relative'>{children}</main>
|
||||
</>
|
||||
) : (
|
||||
<p className='flex justify-center items-center h-screen w-screen text-6xl'>
|
||||
{loading == 0
|
||||
? 'Loading'
|
||||
: loading == 1
|
||||
? 'Failed to load launcher'
|
||||
: 'Outdated launcher!'}
|
||||
</p>
|
||||
)}
|
||||
</body>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RootLayout ({
|
||||
children
|
||||
}: Readonly<{
|
||||
@@ -67,24 +97,11 @@ export default function RootLayout ({
|
||||
|
||||
return (
|
||||
<html lang='en' className={roboto.className}>
|
||||
<body className='dark-theme'>
|
||||
<LauncherContext.Provider value={{ launcherManifest }}>
|
||||
{loading == 3 ? (
|
||||
<>
|
||||
<Sidebar />
|
||||
<main className='ml-60 relative'>{children}</main>
|
||||
</>
|
||||
) : (
|
||||
<p className='flex justify-center items-center h-screen w-screen text-6xl'>
|
||||
{loading == 0
|
||||
? 'Loading'
|
||||
: loading == 1
|
||||
? 'Failed to load launcher'
|
||||
: 'Outdated launcher!'}
|
||||
</p>
|
||||
)}
|
||||
</LauncherContext.Provider>
|
||||
</body>
|
||||
<GlobalProvider.Provider value={{ launcherManifest }}>
|
||||
<SettingsProvider>
|
||||
<RootLayoutContent loading={loading}>{children}</RootLayoutContent>
|
||||
</SettingsProvider>
|
||||
</GlobalProvider.Provider>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
|
||||
export default function Home () {
|
||||
export default function HomePage () {
|
||||
return <div></div>
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { Setting } from '@/componets/Setting'
|
||||
import { copyToClipboard } from '@/lib/clipboard'
|
||||
import { useSettings } from '@/providers/SettingsProvider'
|
||||
import { app } from '@tauri-apps/api'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function SettingsPage () {
|
||||
const {
|
||||
notificationsAllowed,
|
||||
sidebarAlwaysShowGames,
|
||||
linuxUseWine,
|
||||
linuxWineCommand,
|
||||
theme,
|
||||
settings
|
||||
} = useSettings()
|
||||
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
app.getVersion().then(setVersion)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className='text-3xl ml-4 mt-4'>Settings</p>
|
||||
<div className='ml-4 mt-4 bg-(--col1) border border-(--col3) rounded-lg p-4 w-fit h-fit'>
|
||||
<Setting
|
||||
label='Allow sending notifications'
|
||||
value={notificationsAllowed}
|
||||
onChange={async () => {
|
||||
await settings?.set('notificationsAllowed', !notificationsAllowed)
|
||||
}}
|
||||
title='This setting does as you expect, allow the launcher to send notifications for when stuff like downloading is done.'
|
||||
/>
|
||||
<Setting
|
||||
label='Always show games in sidebar'
|
||||
value={sidebarAlwaysShowGames}
|
||||
onChange={async () => {
|
||||
await settings?.set(
|
||||
'sidebarAlwaysShowGames',
|
||||
!sidebarAlwaysShowGames
|
||||
)
|
||||
}}
|
||||
title="This setting will make it so when you are on a page like this, the games won't disappear."
|
||||
/>
|
||||
<Setting
|
||||
label='Use wine if needed'
|
||||
value={linuxUseWine}
|
||||
onChange={async () => {
|
||||
await settings?.set('linuxUseWine', !linuxUseWine)
|
||||
}}
|
||||
className={platform() == 'linux' ? '' : 'hidden'}
|
||||
/>
|
||||
<p hidden={!(platform() == 'linux' && linuxUseWine)}>Wine Command:</p>
|
||||
<input
|
||||
type='text'
|
||||
value={linuxWineCommand}
|
||||
onChange={async e => {
|
||||
await settings?.set('linuxWineCommand', e.target.value)
|
||||
}}
|
||||
className={`input-field my-1 ${
|
||||
platform() == 'linux' && linuxWineCommand ? '' : 'hidden'
|
||||
}`}
|
||||
/>
|
||||
<div title='The theme you want the launcher to use.'>
|
||||
<label className='text-lg'>Theme:</label>
|
||||
<select
|
||||
className='ml-2 bg-(--col2) border border-(--col4) rounded-md'
|
||||
value={theme}
|
||||
onChange={async e => {
|
||||
await settings?.set('theme', e.target.value)
|
||||
}}
|
||||
>
|
||||
<option value={'dark'}>Dark (default)</option>
|
||||
<option value={'red'}>Red</option>
|
||||
<option value={'blue'}>Blue</option>
|
||||
<option value={'purple'}>Purple</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className='fixed bottom-1.5 right-1.5 rounded-md cursor-pointer px-1 border z-100 transition-colors btntheme1'
|
||||
onClick={async () => {
|
||||
await copyToClipboard('v' + version, true)
|
||||
}}
|
||||
title='The current launcher version.'
|
||||
>
|
||||
v{version}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '@/styles/setting.css'
|
||||
import { SettingProps } from '@/types/SettingProps'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
export function Setting ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
title
|
||||
}: SettingProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 mb-2 ${className}`} title={title}>
|
||||
<label className='text-white text-lg'>{label}</label>
|
||||
<div className='setting-checkbox-wrapper'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='setting-checkbox'
|
||||
checked={value}
|
||||
onChange={() => onChange(!value)}
|
||||
/>
|
||||
{value && <FontAwesomeIcon icon={faCheck} className='fa-check-icon' />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import '@/styles/sidebar.css'
|
||||
import { Lexend } from 'next/font/google'
|
||||
import Image from 'next/image'
|
||||
|
||||
import Icon from '@/assets/Icon.png'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCog } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faDiscord } from '@fortawesome/free-brands-svg-icons'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
|
||||
@@ -15,13 +18,22 @@ const lexend = Lexend({
|
||||
})
|
||||
|
||||
export default function Sidebar () {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className={'sidebar ' + lexend.className}>
|
||||
<aside className='sidebar'>
|
||||
<div className='logo'>
|
||||
<Image src={Icon} width={36} height={36} alt='' />
|
||||
<p>Lncvrt Games Launcher</p>
|
||||
<p className={lexend.className}>Lncvrt Games Launcher</p>
|
||||
</div>
|
||||
<nav>
|
||||
<Link
|
||||
draggable={false}
|
||||
href='/settings'
|
||||
className={`link ${pathname === '/settings' ? 'active' : ''}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCog} className='mr-1' /> Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => openUrl('https://games.lncvrt.xyz/discord')}
|
||||
className='link mr-auto'
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type LauncherManifest from '@/types/LauncherManifest'
|
||||
|
||||
type LauncherContextType = {
|
||||
launcherManifest: LauncherManifest | null
|
||||
}
|
||||
|
||||
const LauncherContext = createContext<LauncherContextType | null>(null)
|
||||
|
||||
export const useLauncher = () => useContext(LauncherContext)
|
||||
|
||||
export default LauncherContext
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type LauncherManifest from '@/types/LauncherManifest'
|
||||
|
||||
type GlobalProviderType = {
|
||||
launcherManifest: LauncherManifest | null
|
||||
}
|
||||
|
||||
const GlobalProvider = createContext<GlobalProviderType | null>(null)
|
||||
|
||||
export const useLauncher = () => useContext(GlobalProvider)
|
||||
|
||||
export default GlobalProvider
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode
|
||||
} from 'react'
|
||||
import { load, Store } from '@tauri-apps/plugin-store'
|
||||
|
||||
type SettingsContextType = {
|
||||
theme: string
|
||||
notificationsAllowed: boolean
|
||||
sidebarAlwaysShowGames: boolean
|
||||
linuxUseWine: boolean
|
||||
linuxWineCommand: string
|
||||
settings: Store | null
|
||||
}
|
||||
|
||||
const SettingsCtx = createContext<SettingsContextType | null>(null)
|
||||
|
||||
export const useSettings = () => {
|
||||
const context = useContext(SettingsCtx)
|
||||
if (!context) throw new Error('useSettings must be inside SettingsProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function SettingsProvider ({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState('dark')
|
||||
const [notificationsAllowed, setNotificationsAllowedState] = useState(false)
|
||||
const [sidebarAlwaysShowGames, setSidebarAlwaysShowGamesState] =
|
||||
useState(false)
|
||||
const [linuxUseWine, setLinuxUseWineState] = useState(false)
|
||||
const [linuxWineCommand, setLinuxWineCommandState] = useState('wine %path%')
|
||||
const [store, setStore] = useState<Store | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
const settingsStore = await load('settings.json', {
|
||||
autoSave: true,
|
||||
defaults: {
|
||||
theme: 'dark',
|
||||
notificationsAllowed: false,
|
||||
sidebarAlwaysShowGames: false,
|
||||
linuxUseWine: false,
|
||||
linuxWineCommand: 'wine %path%'
|
||||
}
|
||||
})
|
||||
setStore(settingsStore)
|
||||
|
||||
const themeValue = (await settingsStore.get('theme')) as string
|
||||
const notificationsValue = (await settingsStore.get(
|
||||
'notificationsAllowed'
|
||||
)) as boolean
|
||||
const sidebarValue = (await settingsStore.get(
|
||||
'sidebarAlwaysShowGames'
|
||||
)) as boolean
|
||||
const wineValue = (await settingsStore.get('linuxUseWine')) as boolean
|
||||
const wineCommandValue = (await settingsStore.get(
|
||||
'linuxWineCommand'
|
||||
)) as string
|
||||
|
||||
setThemeState(themeValue)
|
||||
setNotificationsAllowedState(notificationsValue)
|
||||
setSidebarAlwaysShowGamesState(sidebarValue)
|
||||
setLinuxUseWineState(wineValue)
|
||||
setLinuxWineCommandState(wineCommandValue)
|
||||
|
||||
settingsStore.onKeyChange('theme', (value: unknown) =>
|
||||
setThemeState(value as string)
|
||||
)
|
||||
settingsStore.onKeyChange('notificationsAllowed', (value: unknown) =>
|
||||
setNotificationsAllowedState(value as boolean)
|
||||
)
|
||||
settingsStore.onKeyChange('sidebarAlwaysShowGames', (value: unknown) =>
|
||||
setSidebarAlwaysShowGamesState(value as boolean)
|
||||
)
|
||||
settingsStore.onKeyChange('linuxUseWine', (value: unknown) =>
|
||||
setLinuxUseWineState(value as boolean)
|
||||
)
|
||||
settingsStore.onKeyChange('linuxWineCommand', (value: unknown) =>
|
||||
setLinuxWineCommandState(value as string)
|
||||
)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsCtx.Provider
|
||||
value={{
|
||||
theme,
|
||||
notificationsAllowed,
|
||||
sidebarAlwaysShowGames,
|
||||
linuxUseWine,
|
||||
linuxWineCommand,
|
||||
settings: store
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsCtx.Provider>
|
||||
)
|
||||
}
|
||||
@@ -55,3 +55,23 @@ body {
|
||||
--col8: rgb(124, 112, 136);
|
||||
--col9: rgb(140, 128, 152);
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply rounded-md cursor-pointer text-[16px] py-1 px-2 transition-colors border;
|
||||
}
|
||||
|
||||
.btntheme1 {
|
||||
@apply bg-(--col2) border-(--col4) hover:bg-(--col4) hover:border-(--col6) disabled:text-gray-300 disabled:bg-(--col1) disabled:border-(--col5) disabled:hover:text-gray-100 disabled:hover:bg-(--col2) disabled:hover:border-(--col6);
|
||||
}
|
||||
|
||||
.btntheme2,
|
||||
.downloads-entry .entry-info-item,
|
||||
.downloads-entry .button {
|
||||
@apply bg-(--col3) border-(--col5) hover:bg-(--col5) hover:border-(--col7) disabled:text-gray-300 disabled:bg-(--col2) disabled:border-(--col6) disabled:hover:text-gray-100 disabled:hover:bg-(--col3) disabled:hover:border-(--col7);
|
||||
}
|
||||
|
||||
.btntheme3,
|
||||
.downloads-entry:hover .entry-info-item,
|
||||
.downloads-entry:hover .button {
|
||||
@apply bg-(--col4) border-(--col6) hover:bg-(--col6) hover:border-(--col8) disabled:text-gray-300 disabled:bg-(--col2) disabled:border-(--col7) disabled:hover:text-gray-100 disabled:hover:bg-(--col4) disabled:hover:border-(--col8);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
.setting-checkbox-wrapper {
|
||||
@apply relative w-5 h-5;
|
||||
}
|
||||
|
||||
.setting-checkbox {
|
||||
@apply appearance-none w-full h-full border-2 border-(--col4) rounded-md bg-(--col2) transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.setting-checkbox:checked {
|
||||
@apply bg-(--col4) border-(--col6);
|
||||
}
|
||||
|
||||
.fa-check-icon {
|
||||
@apply absolute top-1/2 left-1/2 text-white text-[11px] pointer-events-none -translate-x-2/4 -translate-y-2/4;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type SettingProps = {
|
||||
label: string
|
||||
value: boolean
|
||||
onChange: (val: boolean) => void
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
Reference in new issue
Block a user