Skip to content

Commit 9f3677b

Browse files
committed
feat: enhance branding settings with colored title support and examples
1 parent b97284d commit 9f3677b

File tree

6 files changed

+200
-25
lines changed

6 files changed

+200
-25
lines changed

public/locales/en/remnawave.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1740,7 +1740,13 @@
17401740
"branding-settings": "Branding Settings",
17411741
"the-title-that-will-be-displayed-on-login-page": "The title that will be displayed on login page",
17421742
"brand-name": "Brand Name",
1743-
"the-url-to-your-brand-logo-image": "The URL to your brand logo image"
1743+
"the-url-to-your-brand-logo-image": "The URL to your brand logo image",
1744+
"colored-title-format": "Colored Title Format",
1745+
"colored-title-description": "You can color individual words using {color}word syntax. Supports both HEX colors (#RRGGBB) and MantineUI color names.",
1746+
"example-hex-colors": "Example with HEX colors",
1747+
"example-mantine-colors": "Example with MantineUI colors",
1748+
"example-mixed": "Example with mixed colors",
1749+
"color-format-note": "💡 Color applies only to the word immediately following the marker. Without markers, text will be white."
17441750
}
17451751
},
17461752
"api-tokens-card": {

src/pages/auth/login/login.page.tsx

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Badge, Box, Center, Divider, Group, Image, Stack, Text, Title } from '@mantine/core'
22
import { GetStatusCommand } from '@remnawave/backend-contract'
3-
import { useLayoutEffect } from 'react'
3+
import { useLayoutEffect, useMemo } from 'react'
44

55
import { TelegramLoginButtonFeature } from '@features/auth/telegram-login-button/telegram-login-button.feature'
66
import { OAuth2LoginButtonsFeature } from '@features/auth/oauth2-login-button/oauth2-login-button.feature'
@@ -9,6 +9,7 @@ import { useGetAuthStatus } from '@shared/api/hooks/auth/auth.query.hooks'
99
import { RegisterFormFeature } from '@features/auth/register-form'
1010
import { LoginFormFeature } from '@features/auth/login-form'
1111
import { clearQueryClient } from '@shared/api/query-client'
12+
import { parseColoredTextUtil } from '@shared/utils/misc'
1213
import { LoadingScreen } from '@shared/ui'
1314
import { Logo } from '@shared/ui/logo'
1415
import { Page } from '@shared/ui/page'
@@ -50,25 +51,22 @@ const BrandLogo = ({ logoUrl }: { logoUrl?: null | string }) => {
5051
)
5152
}
5253

53-
const BrandTitle = ({ title }: { title?: null | string }) => {
54-
if (!title) {
55-
return (
56-
<Title ff="Unbounded" order={1} pos="relative">
57-
<Text c="cyan" component="span" fw="inherit" fz="inherit" pos="relative">
58-
Remna
59-
</Text>
60-
<Text c="white" component="span" fw="inherit" fz="inherit" pos="relative">
61-
wave
62-
</Text>
63-
</Title>
64-
)
65-
}
66-
54+
const BrandTitle = ({ titleParts }: { titleParts: Array<{ color: string; text: string }> }) => {
6755
return (
6856
<Title ff="Unbounded" order={1} pos="relative">
69-
<Text c="white" component="span" fw="inherit" fz="inherit" pos="relative">
70-
{title}
71-
</Text>
57+
{titleParts.map((part, index) => (
58+
<Text
59+
c={part.color || 'white'}
60+
component="span"
61+
fw="inherit"
62+
fz="inherit"
63+
inherit
64+
key={index}
65+
pos="relative"
66+
>
67+
{part.text}
68+
</Text>
69+
))}
7270
</Title>
7371
)
7472
}
@@ -108,6 +106,17 @@ export const LoginPage = () => {
108106
clearQueryClient()
109107
}, [])
110108

109+
const titleParts = useMemo(() => {
110+
if (authStatus?.branding.title) {
111+
return parseColoredTextUtil(authStatus.branding.title)
112+
}
113+
114+
return [
115+
{ text: 'Remna', color: 'cyan' },
116+
{ text: 'wave', color: 'white' }
117+
]
118+
}, [authStatus?.branding.title])
119+
111120
if (isFetching) {
112121
return <LoadingScreen height="60vh" />
113122
}
@@ -120,7 +129,7 @@ export const LoginPage = () => {
120129
<Stack align="center" gap="xs">
121130
<Group align="center" gap={4} justify="center">
122131
<BrandLogo logoUrl={authStatus?.branding.logoUrl} />
123-
<BrandTitle title={authStatus?.branding.title} />
132+
<BrandTitle titleParts={titleParts} />
124133
</Group>
125134

126135
{!authStatus && (
Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
import { Text } from '@mantine/core'
2+
import { useMemo } from 'react'
23

34
import { useGetAuthStatus } from '@shared/api/hooks/auth/auth.query.hooks'
5+
import { parseColoredTextUtil } from '@shared/utils/misc'
46

57
import classes from './sidebar.module.css'
68

79
export const SidebarTitleShared = () => {
810
const { data: authStatus } = useGetAuthStatus()
911

12+
const titleParts = useMemo(() => {
13+
if (authStatus?.branding.title) {
14+
return parseColoredTextUtil(authStatus.branding.title)
15+
}
16+
17+
return [
18+
{ text: 'Remna', color: 'cyan' },
19+
{ text: 'wave', color: 'white' }
20+
]
21+
}, [authStatus?.branding.title])
22+
1023
return (
1124
<Text className={classes.logoTitle}>
12-
<Text c={authStatus?.branding.title ? 'white' : 'cyan'} component="span" inherit>
13-
{authStatus?.branding.title || 'Remna'}
14-
</Text>
15-
{authStatus?.branding.title ? '' : 'wave'}
25+
{titleParts.map((part, index) => (
26+
<Text c={part.color || 'white'} component="span" inherit key={index}>
27+
{part.text}
28+
</Text>
29+
))}
1630
</Text>
1731
)
1832
}

src/shared/utils/misc/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './form'
66
export * from './is'
77
export * from './match'
88
export * from './number'
9+
export * from './parse-colored-text'
910
export * from './prevent-back-scroll'
1011
export * from './run-on-delay'
1112
export * from './sleep'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Parses a string containing color-annotated segments and returns an array of
3+
* text parts with associated colors.
4+
*
5+
* The function looks for segments in the format `{color}word`, where `color` can be
6+
* a six-digit hex code (with or without a leading #) or any string representing a color,
7+
* and `word` is the sequence of non-whitespace, non-brace characters that follows.
8+
*
9+
* All other text (plain, outside braces) is grouped as "normal" and assigned the color `'white'`.
10+
*
11+
* Examples:
12+
* - "{ff0000}Remna{00ff00}wave" =>
13+
* [
14+
* { text: "Remna", color: "#ff0000" },
15+
* { text: "wave", color: "#00ff00" }
16+
* ]
17+
* - "{blue}My Brand" =>
18+
* [
19+
* { text: "My", color: "blue" },
20+
* { text: " Brand", color: "white" }
21+
* ]
22+
*
23+
* @param text - The text to parse, possibly containing color-annotated segments.
24+
* @returns An array of objects, each with `text` and `color` properties.
25+
*/
26+
export function parseColoredTextUtil(text: string): Array<{ color: string; text: string }> {
27+
const parts: Array<{ color: string; text: string }> = []
28+
let i = 0
29+
30+
while (i < text.length) {
31+
if (text[i] === '{') {
32+
const closeIndex = text.indexOf('}', i)
33+
if (closeIndex !== -1) {
34+
let color = text.slice(i + 1, closeIndex)
35+
36+
if (/^[0-9A-Fa-f]{6}$/.test(color)) {
37+
color = `#${color}`
38+
}
39+
40+
i = closeIndex + 1
41+
42+
let word = ''
43+
while (i < text.length && text[i] !== ' ' && text[i] !== '{') {
44+
word += text[i]
45+
i++
46+
}
47+
48+
if (word) {
49+
parts.push({ text: word, color })
50+
}
51+
}
52+
}
53+
54+
let normalText = ''
55+
while (i < text.length && text[i] !== '{') {
56+
normalText += text[i]
57+
i++
58+
}
59+
60+
if (normalText) {
61+
parts.push({ text: normalText, color: 'white' })
62+
}
63+
}
64+
65+
return parts.length > 0 ? parts : [{ text, color: 'white' }]
66+
}

src/widgets/remnawave-settings/branding-settings-card/branding-settings-card.widget.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1+
import {
2+
ActionIcon,
3+
Box,
4+
Button,
5+
Code,
6+
Divider,
7+
Group,
8+
HoverCard,
9+
Stack,
10+
Text,
11+
TextInput,
12+
ThemeIcon
13+
} from '@mantine/core'
114
import {
215
GetRemnawaveSettingsCommand,
316
UpdateRemnawaveSettingsCommand
417
} from '@remnawave/backend-contract'
5-
import { Button, Code, Group, Stack, Text, TextInput, ThemeIcon } from '@mantine/core'
618
import { TbAlertCircle, TbLink, TbStar } from 'react-icons/tb'
719
import { zodResolver } from 'mantine-form-zod-resolver'
20+
import { HiQuestionMarkCircle } from 'react-icons/hi'
821
import { useTranslation } from 'react-i18next'
922
import { modals } from '@mantine/modals'
1023
import { useForm } from '@mantine/form'
@@ -108,6 +121,71 @@ export const BrandingSettingsCardWidget = (props: IProps) => {
108121
})
109122
})
110123

124+
const brandingTitleHoverCard = () => {
125+
return (
126+
<HoverCard shadow="md" width={350} withArrow>
127+
<HoverCard.Target>
128+
<ActionIcon color="gray" size="xs" variant="subtle">
129+
<HiQuestionMarkCircle size={20} />
130+
</ActionIcon>
131+
</HoverCard.Target>
132+
<HoverCard.Dropdown>
133+
<Stack gap="md">
134+
<Stack gap="sm">
135+
<Stack gap={4}>
136+
<Text fw={600} size="sm">
137+
{t('branding-settings-card.widget.colored-title-format')}
138+
</Text>
139+
<Text c="dimmed" size="xs">
140+
{t('branding-settings-card.widget.colored-title-description')}
141+
</Text>
142+
</Stack>
143+
144+
<Divider />
145+
146+
<Stack gap={8}>
147+
<Box>
148+
<Text c="dimmed" fw={500} mb={4} size="xs">
149+
{t('branding-settings-card.widget.example-hex-colors')}:
150+
</Text>
151+
<Code block c="blue" fz="xs">
152+
{'{#B8F2E6}Re{#FFA69E}mna{#AEC6CF}wave'}
153+
</Code>
154+
</Box>
155+
156+
<Box>
157+
<Text c="dimmed" fw={500} mb={4} size="xs">
158+
{t('branding-settings-card.widget.example-mantine-colors')}:
159+
</Text>
160+
<Code block c="blue" fz="xs">
161+
{'{cyan}Remna{white}wave'}
162+
</Code>
163+
</Box>
164+
165+
<Box>
166+
<Text c="dimmed" fw={500} mb={4} size="xs">
167+
{t('branding-settings-card.widget.example-mixed')}:
168+
</Text>
169+
<Code block c="blue" fz="xs">
170+
{'{#B8F2E6}Re{#FFA69E}mna{cyan}wave'}
171+
</Code>
172+
</Box>
173+
</Stack>
174+
175+
<Divider />
176+
177+
<Box>
178+
<Text c="dimmed" size="xs">
179+
{t('branding-settings-card.widget.color-format-note')}
180+
</Text>
181+
</Box>
182+
</Stack>
183+
</Stack>
184+
</HoverCard.Dropdown>
185+
</HoverCard>
186+
)
187+
}
188+
111189
return (
112190
<>
113191
<form onSubmit={handleSubmit} style={{ display: 'contents' }}>
@@ -130,6 +208,7 @@ export const BrandingSettingsCardWidget = (props: IProps) => {
130208
label={t('branding-settings-card.widget.brand-name')}
131209
leftSection={<TbStar size={16} />}
132210
placeholder="Remnawave"
211+
rightSection={brandingTitleHoverCard()}
133212
{...form.getInputProps('brandingSettings.title')}
134213
/>
135214

0 commit comments

Comments
 (0)