[V2] refactor(ui): replace native inputs with Mantine components (#4898)

# Description of Changes

#### Before
<img width="779" height="768" alt="image"
src="https://github.com/user-attachments/assets/a6d63bac-e27d-4bd8-b44e-fa72333f4c59"
/>


#### After
<img width="779" height="768" alt="image"
src="https://github.com/user-attachments/assets/f39d8d92-802a-40ff-a7db-2b6a187b6847"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
Balázs Szücs
2025-12-25 01:23:49 +00:00
committed by GitHub
co-authored by Reece Browne
parent 1810a12cf3
commit a3e1cc8393
8 changed files with 102 additions and 119 deletions
@@ -3800,6 +3800,11 @@ failed = "An error occurred while compressing the PDF."
_value = "Compression Settings" _value = "Compression Settings"
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality" 1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
[compress.compressionLevel]
range1to3 = "Lower values preserve quality but result in larger files"
range4to6 = "Medium compression with moderate quality reduction"
range7to9 = "Higher values reduce file size significantly but may reduce image clarity"
[decrypt] [decrypt]
passwordPrompt = "This file is password-protected. Please enter the password:" passwordPrompt = "This file is password-protected. Please enter the password:"
cancelled = "Operation cancelled for PDF: {0}" cancelled = "Operation cancelled for PDF: {0}"
@@ -8,6 +8,7 @@ interface Props {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
suffix?: string;
} }
export default function SliderWithInput({ export default function SliderWithInput({
@@ -18,11 +19,12 @@ export default function SliderWithInput({
min = 0, min = 0,
max = 200, max = 200,
step = 1, step = 1,
suffix = '%',
}: Props) { }: Props) {
return ( return (
<div> <div>
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text> <Text size="sm" fw={500} mb={8}>{label}</Text>
<Group gap="sm" align="center"> <Group gap="md" align="center">
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} /> <Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
</div> </div>
@@ -33,6 +35,7 @@ export default function SliderWithInput({
max={max} max={max}
step={step} step={step}
disabled={disabled} disabled={disabled}
suffix={suffix}
style={{ width: 90 }} style={{ width: 90 }}
/> />
</Group> </Group>
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core"; import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
import SliderWithInput from '@app/components/shared/sliderWithInput/SliderWithInput';
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters"; import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector"; import ButtonSelector from "@app/components/shared/ButtonSelector";
@@ -13,7 +14,6 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => { const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isSliding, setIsSliding] = useState(false);
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null); const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
@@ -47,57 +47,22 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Quality Adjustment */} {/* Quality Adjustment */}
{parameters.compressionMethod === 'quality' && ( {parameters.compressionMethod === 'quality' && (
<Stack gap="sm"> <Stack gap="md">
<Divider /> <Divider />
<Text size="sm" fw={500}>Compression Level</Text> <SliderWithInput
<div style={{ position: 'relative' }}> label={t('compress.tooltip.qualityAdjustment.title', 'Compression Level')}
<input
type="range"
min="1"
max="9"
step="1"
value={parameters.compressionLevel} value={parameters.compressionLevel}
onChange={(e) => onParameterChange('compressionLevel', parseInt(e.target.value))} onChange={(value) => onParameterChange('compressionLevel', value)}
onMouseDown={() => setIsSliding(true)}
onMouseUp={() => setIsSliding(false)}
onTouchStart={() => setIsSliding(true)}
onTouchEnd={() => setIsSliding(false)}
disabled={disabled} disabled={disabled}
style={{ min={1}
width: '100%', max={9}
height: '6px', step={1}
borderRadius: '3px', suffix=""
background: `linear-gradient(to right, #228be6 0%, #228be6 ${(parameters.compressionLevel - 1) / 8 * 100}%, #e9ecef ${(parameters.compressionLevel - 1) / 8 * 100}%, #e9ecef 100%)`,
outline: 'none',
WebkitAppearance: 'none'
}}
/> />
{isSliding && ( <Text size="xs" c="dimmed" mt={-4}>
<div style={{ {parameters.compressionLevel <= 3 && t('compress.compressionLevel.range1to3', 'Lower values preserve quality but result in larger files')}
position: 'absolute', {parameters.compressionLevel >= 4 && parameters.compressionLevel <= 6 && t('compress.compressionLevel.range4to6', 'Medium compression with moderate quality reduction')}
top: '-25px', {parameters.compressionLevel >= 7 && t('compress.compressionLevel.range7to9', 'Higher values reduce file size significantly but may reduce image clarity')}
left: `${(parameters.compressionLevel - 1) / 8 * 100}%`,
transform: 'translateX(-50%)',
background: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px',
padding: '2px 6px',
fontSize: '12px',
color: '#228be6',
whiteSpace: 'nowrap'
}}>
{parameters.compressionLevel}
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#6c757d' }}>
<span>Min 1</span>
<span>Max 9</span>
</div>
<Text size="xs" c="dimmed" style={{ marginTop: '8px' }}>
{parameters.compressionLevel <= 3 && "1-3 PDF compression"}
{parameters.compressionLevel >= 4 && parameters.compressionLevel <= 6 && "4-6 lite image compression"}
{parameters.compressionLevel >= 7 && "7-9 intense image compression Will dramatically reduce image quality"}
</Text> </Text>
</Stack> </Stack>
)} )}
@@ -185,8 +185,8 @@
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
padding: 1rem 1rem; /* 16px 16px */ padding: 0.75rem 1rem; /* 12px 16px */
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.75rem; /* 12px */ border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only); background-color: var(--auth-card-bg-light-only);
@@ -195,6 +195,12 @@
color: var(--auth-text-primary-light-only); color: var(--auth-text-primary-light-only);
cursor: pointer; cursor: pointer;
gap: 0.75rem; /* 12px */ gap: 0.75rem; /* 12px */
font-family: inherit;
transition: background-color 0.2s ease;
}
.oauth-button-vertical:hover:not(:disabled) {
background-color: #f3f4f6;
} }
.oauth-button-vertical:disabled { .oauth-button-vertical:disabled {
@@ -202,6 +208,11 @@
opacity: 0.6; opacity: 0.6;
} }
.oauth-button-vertical:focus-visible {
outline: 2px solid var(--auth-border-focus-light-only);
outline-offset: 2px;
}
.oauth-icon-small { .oauth-icon-small {
width: 1.75rem; /* 28px */ width: 1.75rem; /* 28px */
height: 1.75rem; /* 28px */ height: 1.75rem; /* 28px */
@@ -217,6 +228,8 @@
.oauth-icon-tiny { .oauth-icon-tiny {
width: 1.25rem; /* 20px */ width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */ height: 1.25rem; /* 20px */
display: block;
flex-shrink: 0;
} }
/* Login Header Styles */ /* Login Header Styles */
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import '@app/routes/authShared/auth.css'; import '@app/routes/authShared/auth.css';
import { TextInput, PasswordInput, Button } from '@mantine/core';
interface EmailPasswordFormProps { interface EmailPasswordFormProps {
email: string email: string
@@ -38,49 +39,46 @@ export default function EmailPasswordForm({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="auth-fields"> <div className="auth-fields">
<div className="auth-field"> <div className="auth-field">
<label htmlFor="email" className="auth-label">{t('login.username', 'Username')}</label> <TextInput
<input
id="email" id="email"
label={t('login.username', 'Username')}
type="text" type="text"
name="username" name="username"
autoComplete="username" autoComplete="username"
placeholder={t('login.enterUsername', 'Enter username')} placeholder={t('login.enterUsername', 'Enter username')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} error={fieldErrors.email}
classNames={{ label: 'auth-label' }}
/> />
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
</div> </div>
{showPasswordField && ( {showPasswordField && (
<div className="auth-field"> <div className="auth-field">
<label htmlFor="password" className="auth-label">{t('login.password')}</label> <PasswordInput
<input
id="password" id="password"
type="password" label={t('login.password')}
name="current-password" name="current-password"
autoComplete="current-password" autoComplete="current-password"
placeholder={t('login.enterPassword')} placeholder={t('login.enterPassword')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} error={fieldErrors.password}
classNames={{ label: 'auth-label' }}
/> />
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
</div> </div>
)} )}
</div> </div>
<button <Button
type="submit" type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)} disabled={isSubmitting || !email || (showPasswordField && !password)}
className="auth-button" className="auth-button"
fullWidth
loading={isSubmitting}
> >
{submitButtonText} {submitButtonText}
</button> </Button>
</form> </form>
); );
} }
@@ -1,3 +1,5 @@
import { Button } from '@mantine/core';
interface NavigationLinkProps { interface NavigationLinkProps {
onClick: () => void onClick: () => void
text: string text: string
@@ -7,13 +9,14 @@ interface NavigationLinkProps {
export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) { export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) {
return ( return (
<div className="navigation-link-container"> <div className="navigation-link-container">
<button <Button
onClick={onClick} onClick={onClick}
disabled={isDisabled} disabled={isDisabled}
className="navigation-link-button" className="navigation-link-button"
variant="subtle"
> >
{text} {text}
</button> </Button>
</div> </div>
); );
} }
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '@app/constants/app'; import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes'; import { type OAuthProvider } from '@app/auth/oauthTypes';
import { Button } from '@mantine/core';
// Debug flag to show all providers for UI testing // Debug flag to show all providers for UI testing
// Set to true to see all SSO options regardless of backend configuration // Set to true to see all SSO options regardless of backend configuration
@@ -69,14 +70,15 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
<div className="oauth-container-icons"> <div className="oauth-container-icons">
{providers.map((p) => ( {providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}> <div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button <Button
onClick={() => onProviderClick(p.id)} onClick={() => onProviderClick(p.id)}
disabled={isSubmitting} disabled={isSubmitting}
className="oauth-button-icon" className="oauth-button-icon"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`} aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
variant="default"
> >
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-small"/> <img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-small"/>
</button> </Button>
</div> </div>
))} ))}
</div> </div>
@@ -88,14 +90,15 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
<div className="oauth-container-grid"> <div className="oauth-container-grid">
{providers.map((p) => ( {providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}> <div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button <Button
onClick={() => onProviderClick(p.id)} onClick={() => onProviderClick(p.id)}
disabled={isSubmitting} disabled={isSubmitting}
className="oauth-button-grid" className="oauth-button-grid"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`} aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
variant="default"
> >
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-medium"/> <img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-medium"/>
</button> </Button>
</div> </div>
))} ))}
</div> </div>
@@ -105,16 +108,18 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
return ( return (
<div className="oauth-container-vertical"> <div className="oauth-container-vertical">
{providers.map((p) => ( {providers.map((p) => (
<button <div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
key={p.id} <Button
onClick={() => onProviderClick(p.id)} onClick={() => onProviderClick(p.id)}
disabled={isSubmitting} disabled={isSubmitting}
className="oauth-button-vertical" className="oauth-button-vertical"
title={p.label} aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
variant="default"
> >
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-tiny" /> <img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-tiny" />
{p.label} <span>{p.label}</span>
</button> </Button>
</div>
))} ))}
</div> </div>
); );
@@ -1,7 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import '@app/routes/authShared/auth.css'; import '@app/routes/authShared/auth.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Checkbox } from '@mantine/core'; import { Checkbox, TextInput, PasswordInput, Button } from '@mantine/core';
import { SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'; import { SignupFieldErrors } from '@app/routes/signup/SignupFormValidation';
interface SignupFormProps { interface SignupFormProps {
@@ -53,57 +53,49 @@ export default function SignupForm({
<div className="auth-fields"> <div className="auth-fields">
{showName && ( {showName && (
<div className="auth-field"> <div className="auth-field">
<label htmlFor="name" className="auth-label">{t('signup.name')}</label> <TextInput
<input
id="name" id="name"
type="text" label={t('signup.name')}
name="name" name="name"
autoComplete="name" autoComplete="name"
placeholder={t('signup.enterName')} placeholder={t('signup.enterName')}
value={name} value={name}
onChange={(e) => setName?.(e.target.value)} onChange={(e) => setName?.(e.target.value)}
className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`} error={fieldErrors.name}
classNames={{ label: 'auth-label' }}
/> />
{fieldErrors.name && (
<div className="auth-field-error">{fieldErrors.name}</div>
)}
</div> </div>
)} )}
<div className="auth-field"> <div className="auth-field">
<label htmlFor="email" className="auth-label">{t('signup.email')}</label> <TextInput
<input
id="email" id="email"
label={t('signup.email')}
type="email" type="email"
name="email" name="email"
autoComplete="email" autoComplete="email"
placeholder={t('signup.enterEmail')} placeholder={t('signup.enterEmail')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} onKeyDown={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} error={fieldErrors.email}
classNames={{ label: 'auth-label' }}
/> />
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
</div> </div>
<div className="auth-field"> <div className="auth-field">
<label htmlFor="password" className="auth-label">{t('signup.password')}</label> <PasswordInput
<input
id="password" id="password"
type="password" label={t('signup.password')}
name="new-password" name="new-password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t('signup.enterPassword')} placeholder={t('signup.enterPassword')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} onKeyDown={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} error={fieldErrors.password}
classNames={{ label: 'auth-label' }}
/> />
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
</div> </div>
<div <div
@@ -112,21 +104,18 @@ export default function SignupForm({
style={{ maxHeight: showConfirm ? 96 : 0, opacity: showConfirm ? 1 : 0 }} style={{ maxHeight: showConfirm ? 96 : 0, opacity: showConfirm ? 1 : 0 }}
> >
<div className="auth-field"> <div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">{t('signup.confirmPassword')}</label> <PasswordInput
<input
id="confirmPassword" id="confirmPassword"
type="password" label={t('signup.confirmPassword')}
name="new-password" name="new-password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t('signup.confirmPasswordPlaceholder')} placeholder={t('signup.confirmPasswordPlaceholder')}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} onKeyDown={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className={`auth-input ${fieldErrors.confirmPassword ? 'auth-input-error' : ''}`} error={fieldErrors.confirmPassword}
classNames={{ label: 'auth-label' }}
/> />
{fieldErrors.confirmPassword && (
<div className="auth-field-error">{fieldErrors.confirmPassword}</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -152,13 +141,15 @@ export default function SignupForm({
)} )}
{/* Sign Up Button */} {/* Sign Up Button */}
<button <Button
onClick={onSubmit} onClick={onSubmit}
disabled={isSubmitting || !email || !password || !confirmPassword || (showTerms && !agree)} disabled={isSubmitting || !email || !password || !confirmPassword || (showTerms && !agree)}
className="auth-button" className="auth-button"
fullWidth
loading={isSubmitting}
> >
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')} {isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
</button> </Button>
</> </>
); );
} }