Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412aae9d8f | ||
|
|
e4c6ce5836 | ||
|
|
6d8e458aac | ||
|
|
7733eed4b8 | ||
|
|
c9578b9ea3 | ||
|
|
289e75b872 | ||
|
|
250979e271 |
@@ -159,6 +159,8 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|
||||
@@ -58,6 +58,21 @@ public class UIDataController {
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
@GetMapping("/footer-info")
|
||||
@Operation(summary = "Get public footer configuration data")
|
||||
public ResponseEntity<FooterData> getFooterData() {
|
||||
FooterData data = new FooterData();
|
||||
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
|
||||
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
|
||||
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
|
||||
data.setAccessibilityStatement(
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
|
||||
data.setImpressum(applicationProperties.getLegal().getImpressum());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/home")
|
||||
@Operation(summary = "Get home page data")
|
||||
public ResponseEntity<HomeData> getHomeData() {
|
||||
@@ -237,6 +252,16 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
// Data classes
|
||||
@Data
|
||||
public static class FooterData {
|
||||
private Boolean analyticsEnabled;
|
||||
private String termsAndConditions;
|
||||
private String privacyPolicy;
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HomeData {
|
||||
private boolean showSurveyFromDocker;
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
|
||||
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
|
||||
|
||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
||||
|
||||
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
|
||||
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
|
||||
@@ -34,10 +34,10 @@
|
||||
--cc-section-category-border: var(--md-sys-color-outline);
|
||||
|
||||
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
|
||||
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
*/
|
||||
--cc-footer-bg: var(--cc-bg);
|
||||
--cc-footer-color: var(--cc-primary-color);
|
||||
--cc-footer-border-color: var(--cc-bg);
|
||||
--cc-footer-border-color: var(--cc-bg);
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
@@ -81,4 +81,4 @@
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -299,7 +299,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/api/v1/invite/validate",
|
||||
contextPath + "/api/v1/invite/accept"
|
||||
contextPath + "/api/v1/invite/accept",
|
||||
contextPath + "/api/v1/ui-data/footer-info"
|
||||
};
|
||||
|
||||
for (String pattern : publicApiPatterns) {
|
||||
|
||||
@@ -54,17 +54,17 @@
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #2d2d2d;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #e5e5e5;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #e5e5e5;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #2d2d2d;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
@@ -180,4 +180,27 @@
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
#cc-main .cm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__desc {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer-links a,
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
@@ -5777,7 +5777,7 @@ description = "Sign in with your Stirling account"
|
||||
|
||||
[setup.mode.selfhosted]
|
||||
title = "Self-Hosted Server"
|
||||
description = "Connect to your own Stirling PDF server"
|
||||
description = "Connect to your own Stirling PDF server with your personal account"
|
||||
|
||||
[setup.saas]
|
||||
title = "Sign in to Stirling"
|
||||
@@ -5813,6 +5813,13 @@ submit = "Login"
|
||||
signInWith = "Sign in with"
|
||||
oauthPending = "Opening browser for authentication..."
|
||||
orContinueWith = "Or continue with email"
|
||||
serverRequirement = "Note: The server must have login enabled."
|
||||
showInstructions = "How to enable?"
|
||||
hideInstructions = "Hide instructions"
|
||||
instructions = "To enable login on your Stirling PDF server:"
|
||||
instructionsEnvVar = "Set the environment variable:"
|
||||
instructionsOrYml = "Or in settings.yml:"
|
||||
instructionsRestart = "Then restart your server for the changes to take effect."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Username"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
import { useFooterInfo } from '@app/hooks/useFooterInfo';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
@@ -9,6 +10,7 @@ interface FooterProps {
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
analyticsEnabled?: boolean;
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
@@ -17,10 +19,36 @@ export default function Footer({
|
||||
accessibilityStatement,
|
||||
cookiePolicy,
|
||||
impressum,
|
||||
analyticsEnabled = false
|
||||
analyticsEnabled,
|
||||
forceLightMode = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
const { footerInfo } = useFooterInfo();
|
||||
|
||||
console.log('[Footer] Props analyticsEnabled:', analyticsEnabled);
|
||||
console.log('[Footer] Fetched footerInfo:', footerInfo);
|
||||
|
||||
// Use props if provided, otherwise fall back to fetched footer info
|
||||
const finalAnalyticsEnabled = analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
|
||||
const finalPrivacyPolicy = privacyPolicy ?? footerInfo?.privacyPolicy;
|
||||
const finalTermsAndConditions = termsAndConditions ?? footerInfo?.termsAndConditions;
|
||||
const finalAccessibilityStatement = accessibilityStatement ?? footerInfo?.accessibilityStatement;
|
||||
const finalCookiePolicy = cookiePolicy ?? footerInfo?.cookiePolicy;
|
||||
const finalImpressum = impressum ?? footerInfo?.impressum;
|
||||
|
||||
console.log('[Footer] Final analyticsEnabled:', finalAnalyticsEnabled);
|
||||
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled: finalAnalyticsEnabled, forceLightMode });
|
||||
|
||||
// Default URLs
|
||||
const defaultTermsUrl = "https://www.stirling.com/legal/terms-of-service";
|
||||
const defaultPrivacyUrl = "https://www.stirling.com/legal/privacy-policy";
|
||||
const defaultAccessibilityUrl = "https://www.stirling.com/accessibility";
|
||||
|
||||
// Use provided URLs or fall back to defaults
|
||||
const finalTermsUrl = finalTermsAndConditions || defaultTermsUrl;
|
||||
const finalPrivacyUrl = finalPrivacyPolicy || defaultPrivacyUrl;
|
||||
const finalAccessibilityUrl = finalAccessibilityStatement || defaultAccessibilityUrl;
|
||||
|
||||
// Helper to check if a value is valid (not null/undefined/empty string)
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
@@ -28,8 +56,8 @@ export default function Footer({
|
||||
return (
|
||||
<div style={{
|
||||
height: 'var(--footer-height)',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
backgroundColor: forceLightMode ? '#f1f3f5' : 'var(--mantine-color-gray-1)',
|
||||
borderTop: forceLightMode ? '1px solid #e9ecef' : '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
@@ -38,7 +66,10 @@ export default function Footer({
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="row"
|
||||
style={{ fontSize: '0.75rem' }}>
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: forceLightMode ? '#495057' : undefined
|
||||
}}>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
@@ -48,57 +79,51 @@ export default function Footer({
|
||||
>
|
||||
{t('survey.nav', 'Survey')}
|
||||
</a>
|
||||
{isValidLink(privacyPolicy) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={finalPrivacyUrl}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={finalTermsUrl}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={finalAccessibilityUrl}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
{isValidLink(finalCookiePolicy) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={privacyPolicy}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(termsAndConditions) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={termsAndConditions}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(accessibilityStatement) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={accessibilityStatement}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(cookiePolicy) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={cookiePolicy}
|
||||
href={finalCookiePolicy}
|
||||
>
|
||||
{t('legal.cookie', 'Cookie Policy')}
|
||||
</a>
|
||||
)}
|
||||
{isValidLink(impressum) && (
|
||||
{isValidLink(finalImpressum) && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={impressum}
|
||||
href={finalImpressum}
|
||||
>
|
||||
{t('legal.impressum', 'Impressum')}
|
||||
</a>
|
||||
)}
|
||||
{analyticsEnabled && (
|
||||
{finalAnalyticsEnabled && (
|
||||
<button
|
||||
className="footer-link px-3"
|
||||
id="cookieBanner"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { Menu, Button, ActionIcon } from '@mantine/core';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '@app/i18n';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
@@ -11,6 +12,7 @@ interface LanguageSelectorProps {
|
||||
position?: React.ComponentProps<typeof Menu>['position'];
|
||||
offset?: number;
|
||||
compact?: boolean; // icon-only trigger
|
||||
tooltip?: string; // tooltip text for compact mode
|
||||
}
|
||||
|
||||
interface LanguageOption {
|
||||
@@ -51,7 +53,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = disabled ? (
|
||||
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
|
||||
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
|
||||
<p>{option.label}</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -64,7 +66,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
style={{
|
||||
opacity: animationTriggered ? 1 : 0,
|
||||
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
|
||||
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
|
||||
transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -90,7 +92,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
transition: 'all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
'&:hover': !disabled ? {
|
||||
backgroundColor: isSelected
|
||||
@@ -126,7 +128,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
backgroundColor: 'var(--mantine-color-blue-4)',
|
||||
opacity: 0.6,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
animation: 'ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -149,7 +151,12 @@ const RippleStyles: React.FC = () => (
|
||||
);
|
||||
|
||||
// Main component
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
position = 'bottom-start',
|
||||
offset = 8,
|
||||
compact = false,
|
||||
tooltip
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
@@ -184,14 +191,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
setOpened(false);
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
setTimeout(() => setRippleEffect(null), 50);
|
||||
|
||||
// Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 300);
|
||||
}, 200);
|
||||
}, 150);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
@@ -202,7 +209,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 50);
|
||||
setTimeout(() => setAnimationTriggered(true), 20);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
@@ -218,7 +225,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
duration: 120,
|
||||
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
>
|
||||
@@ -227,8 +234,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
title={currentLanguage}
|
||||
className="right-rail-icon"
|
||||
title={!opened && tooltip ? tooltip : undefined}
|
||||
styles={{
|
||||
root: {
|
||||
color: 'var(--right-rail-icon)',
|
||||
@@ -274,8 +281,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
const enabledLanguages = [
|
||||
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
|
||||
@@ -301,8 +307,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
|
||||
@@ -216,14 +216,12 @@ export default function RightRail() {
|
||||
tooltipOffset
|
||||
)}
|
||||
|
||||
{renderWithTooltip(
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>,
|
||||
t('rightRail.language', 'Language'),
|
||||
tooltipPosition,
|
||||
tooltipOffset
|
||||
)}
|
||||
<LanguageSelector
|
||||
position="left-start"
|
||||
offset={6}
|
||||
compact
|
||||
tooltip={t('rightRail.language', 'Language')}
|
||||
/>
|
||||
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
|
||||
@@ -19,10 +19,12 @@ declare global {
|
||||
|
||||
interface CookieConsentConfig {
|
||||
analyticsEnabled?: boolean;
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false
|
||||
analyticsEnabled = false,
|
||||
forceLightMode = false
|
||||
}: CookieConsentConfig = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
@@ -69,6 +71,12 @@ export const useCookieConsent = ({
|
||||
|
||||
// Detect current theme and set appropriate mode
|
||||
const detectTheme = () => {
|
||||
// If forceLightMode is enabled, always use light mode
|
||||
if (forceLightMode) {
|
||||
document.documentElement.classList.remove('cc--darkmode');
|
||||
return false;
|
||||
}
|
||||
|
||||
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
const hasLightClass = document.documentElement.classList.contains('light');
|
||||
const hasDarkClass = document.documentElement.classList.contains('dark');
|
||||
@@ -104,21 +112,24 @@ export const useCookieConsent = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
const themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
// Listen for theme changes (but not if forceLightMode is enabled)
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
if (!forceLightMode) {
|
||||
themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Initialize cookie consent with full configuration
|
||||
@@ -245,6 +256,65 @@ export const useCookieConsent = ({
|
||||
};
|
||||
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
|
||||
|
||||
// Update theme when forceLightMode changes
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const detectTheme = () => {
|
||||
if (forceLightMode) {
|
||||
document.documentElement.classList.remove('cc--darkmode');
|
||||
return false;
|
||||
}
|
||||
|
||||
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
const hasLightClass = document.documentElement.classList.contains('light');
|
||||
const hasDarkClass = document.documentElement.classList.contains('dark');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
let isDarkMode = false;
|
||||
if (mantineScheme) {
|
||||
isDarkMode = mantineScheme === 'dark';
|
||||
} else if (hasLightClass) {
|
||||
isDarkMode = false;
|
||||
} else if (hasDarkClass) {
|
||||
isDarkMode = true;
|
||||
} else {
|
||||
isDarkMode = systemPrefersDark;
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
|
||||
return isDarkMode;
|
||||
};
|
||||
|
||||
// Update theme immediately
|
||||
detectTheme();
|
||||
|
||||
// Set up or remove theme observer based on forceLightMode
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
if (!forceLightMode) {
|
||||
themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [forceLightMode, isInitialized]);
|
||||
|
||||
// Hide cookie banner when tour is active
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !window.CookieConsent) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface FooterInfo {
|
||||
analyticsEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch public footer configuration data.
|
||||
* This endpoint is always accessible without authentication.
|
||||
*/
|
||||
export function useFooterInfo() {
|
||||
const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFooterInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('[useFooterInfo] Fetching footer info from /api/v1/ui-data/footer-info...');
|
||||
const response = await apiClient.get<FooterInfo>('/api/v1/ui-data/footer-info', {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
console.log('[useFooterInfo] Footer info received:', response.data);
|
||||
setFooterInfo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[useFooterInfo] Failed to fetch footer info:', err);
|
||||
setError(err as Error);
|
||||
// Set defaults on error
|
||||
setFooterInfo({
|
||||
analyticsEnabled: false,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFooterInfo();
|
||||
}, []);
|
||||
|
||||
return { footerInfo, loading, error };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group } from '@mantine/core';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
@@ -19,6 +19,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -92,6 +93,47 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
{/* Login requirement note for self-hosted servers */}
|
||||
{!isSaaS && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '}
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{showInstructions
|
||||
? t('setup.login.hideInstructions', 'Hide instructions')
|
||||
: t('setup.login.showInstructions', 'How to enable?')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Collapse in={showInstructions}>
|
||||
<Box mt="xs" p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: '4px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.instructions', 'To enable login on your Stirling PDF server:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsEnvVar', 'Set the environment variable:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
SECURITY_ENABLELOGIN=true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsOrYml', 'Or in settings.yml:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
security.enableLogin: true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* OAuth Login Buttons - Only show for SaaS */}
|
||||
{isSaaS && (
|
||||
<>
|
||||
|
||||
@@ -63,7 +63,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server with your personal account')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -101,8 +101,8 @@ export class TauriBackendService {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async waitForPort(maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
private async waitForPort(): Promise<void> {
|
||||
while (true) {
|
||||
try {
|
||||
const port = await invoke<number | null>('get_backend_port');
|
||||
if (port) {
|
||||
@@ -114,7 +114,6 @@ export class TauriBackendService {
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error('Failed to detect backend port after 15 seconds');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,16 +207,14 @@ export class TauriBackendService {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForHealthy(maxAttempts = 60): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
private async waitForHealthy(): Promise<void> {
|
||||
while (true) {
|
||||
const isHealthy = await this.checkBackendHealth();
|
||||
if (isHealthy) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
this.setStatus('unhealthy');
|
||||
throw new Error('Backend failed to become healthy after 60 seconds');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
||||
import { OnboardingProvider } from '@app/contexts/OnboardingContext';
|
||||
|
||||
// Mock i18n to return fallback text
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -65,7 +66,9 @@ vi.mock('react-router-dom', async () => {
|
||||
// Test wrapper with MantineProvider
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>
|
||||
<PreferencesProvider>{children}</PreferencesProvider>
|
||||
<PreferencesProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</PreferencesProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
@@ -67,6 +68,9 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, width: '100%', zIndex: 10 }}>
|
||||
<Footer forceLightMode={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ if errorlevel 1 (
|
||||
echo ✅ Java and jlink detected
|
||||
|
||||
echo ▶ Building Stirling-PDF JAR...
|
||||
|
||||
set DISABLE_ADDITIONAL_FEATURES=true
|
||||
call gradlew.bat clean bootJar --no-daemon
|
||||
if errorlevel 1 (
|
||||
echo ❌ Failed to build Stirling-PDF JAR
|
||||
|
||||
@@ -60,6 +60,7 @@ fi
|
||||
|
||||
# Clean and build the Stirling-PDF JAR
|
||||
print_step "Building Stirling-PDF JAR..."
|
||||
export DISABLE_ADDITIONAL_FEATURES=true
|
||||
./gradlew clean bootJar --no-daemon
|
||||
|
||||
if [ ! -f compgen -G "app/core/build/libs/stirling-pdf-*.jar" ]; then
|
||||
|
||||
Reference in New Issue
Block a user