Merge branch 'release/3.1.1-alfa'
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import copy
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from flask import session
|
||||
@@ -50,8 +52,8 @@ class TranslationServices:
|
||||
if isinstance(config_data, str):
|
||||
config_data = json.loads(config_data)
|
||||
|
||||
# Maak een kopie van de originele data om te wijzigen
|
||||
translated_config = config_data.copy()
|
||||
# Maak een deep copy van de originele data om te wijzigen en input-mutatie te vermijden
|
||||
translated_config = copy.deepcopy(config_data)
|
||||
|
||||
# Haal type en versie op voor de Business Event span
|
||||
config_type = config_data.get('type', 'Unknown')
|
||||
@@ -65,71 +67,124 @@ class TranslationServices:
|
||||
if not context and 'metadata' in config_data and 'description' in config_data['metadata']:
|
||||
description_context = config_data['metadata']['description']
|
||||
|
||||
# Hulpfuncties
|
||||
def is_nonempty_str(val: Any) -> bool:
|
||||
return isinstance(val, str) and val.strip() != ''
|
||||
|
||||
def safe_translate(text: str, ctx: Optional[str]):
|
||||
try:
|
||||
res = cache_manager.translation_cache.get_translation(
|
||||
text=text,
|
||||
target_lang=target_language,
|
||||
source_lang=source_language,
|
||||
context=ctx
|
||||
)
|
||||
return res.translated_text if res else None
|
||||
except Exception as e:
|
||||
if current_event:
|
||||
current_event.log_error('translation_error', {
|
||||
'tenant_id': tenant_id,
|
||||
'config_type': config_type,
|
||||
'config_version': config_version,
|
||||
'field_config': field_config,
|
||||
'error': str(e)
|
||||
})
|
||||
return None
|
||||
|
||||
tag_pair_pattern = re.compile(r'<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>')
|
||||
|
||||
def extract_tag_counts(text: str) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
for m in tag_pair_pattern.finditer(text or ''):
|
||||
tag = m.group(1)
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return counts
|
||||
|
||||
def tags_valid(source: str, translated: str) -> bool:
|
||||
return extract_tag_counts(source) == extract_tag_counts(translated)
|
||||
|
||||
# Counters
|
||||
meta_consentRich_translated_count = 0
|
||||
meta_aria_translated_count = 0
|
||||
meta_inline_tags_invalid_after_translation_count = 0
|
||||
|
||||
# Loop door elk veld in de configuratie
|
||||
for field_name, field_data in fields.items():
|
||||
# Vertaal name als het bestaat en niet leeg is
|
||||
if 'name' in field_data and field_data['name']:
|
||||
# Gebruik context indien opgegeven, anders description_context
|
||||
# Vertaal name als het bestaat en niet leeg is (alleen strings)
|
||||
if 'name' in field_data and is_nonempty_str(field_data['name']):
|
||||
field_context = context if context else description_context
|
||||
translated_name = cache_manager.translation_cache.get_translation(
|
||||
text=field_data['name'],
|
||||
target_lang=target_language,
|
||||
source_lang=source_language,
|
||||
context=field_context
|
||||
)
|
||||
if translated_name:
|
||||
translated_config[field_config][field_name]['name'] = translated_name.translated_text
|
||||
t = safe_translate(field_data['name'], field_context)
|
||||
if t:
|
||||
translated_config[field_config][field_name]['name'] = t
|
||||
|
||||
if 'title' in field_data and field_data['title']:
|
||||
# Gebruik context indien opgegeven, anders description_context
|
||||
if 'title' in field_data and is_nonempty_str(field_data.get('title')):
|
||||
field_context = context if context else description_context
|
||||
translated_title = cache_manager.translation_cache.get_translation(
|
||||
text=field_data['title'],
|
||||
target_lang=target_language,
|
||||
source_lang=source_language,
|
||||
context=field_context
|
||||
)
|
||||
if translated_title:
|
||||
translated_config[field_config][field_name]['title'] = translated_title.translated_text
|
||||
t = safe_translate(field_data['title'], field_context)
|
||||
if t:
|
||||
translated_config[field_config][field_name]['title'] = t
|
||||
|
||||
# Vertaal description als het bestaat en niet leeg is
|
||||
if 'description' in field_data and field_data['description']:
|
||||
# Gebruik context indien opgegeven, anders description_context
|
||||
if 'description' in field_data and is_nonempty_str(field_data.get('description')):
|
||||
field_context = context if context else description_context
|
||||
translated_desc = cache_manager.translation_cache.get_translation(
|
||||
text=field_data['description'],
|
||||
target_lang=target_language,
|
||||
source_lang=source_language,
|
||||
context=field_context
|
||||
)
|
||||
if translated_desc:
|
||||
translated_config[field_config][field_name]['description'] = translated_desc.translated_text
|
||||
t = safe_translate(field_data['description'], field_context)
|
||||
if t:
|
||||
translated_config[field_config][field_name]['description'] = t
|
||||
|
||||
# Vertaal context als het bestaat en niet leeg is
|
||||
if 'context' in field_data and field_data['context']:
|
||||
translated_ctx = cache_manager.translation_cache.get_translation(
|
||||
text=field_data['context'],
|
||||
target_lang=target_language,
|
||||
source_lang=source_language,
|
||||
context=context
|
||||
)
|
||||
if translated_ctx:
|
||||
translated_config[field_config][field_name]['context'] = translated_ctx.translated_text
|
||||
if 'context' in field_data and is_nonempty_str(field_data.get('context')):
|
||||
t = safe_translate(field_data['context'], context)
|
||||
if t:
|
||||
translated_config[field_config][field_name]['context'] = t
|
||||
|
||||
# vertaal allowed values als het veld bestaat en de waarden niet leeg zijn.
|
||||
if 'allowed_values' in field_data and field_data['allowed_values']:
|
||||
# vertaal allowed_values als het veld bestaat en waarden niet leeg zijn (alleen string-items)
|
||||
if 'allowed_values' in field_data and isinstance(field_data['allowed_values'], list) and field_data['allowed_values']:
|
||||
translated_allowed_values = []
|
||||
for allowed_value in field_data['allowed_values']:
|
||||
translated_allowed_value = cache_manager.translation_cache.get_translation(
|
||||
text=allowed_value,
|
||||
target_lang=target_language,
|
||||
source_lang=source_language,
|
||||
context=context
|
||||
)
|
||||
translated_allowed_values.append(translated_allowed_value.translated_text)
|
||||
if is_nonempty_str(allowed_value):
|
||||
t = safe_translate(allowed_value, context)
|
||||
translated_allowed_values.append(t if t else allowed_value)
|
||||
else:
|
||||
translated_allowed_values.append(allowed_value)
|
||||
if translated_allowed_values:
|
||||
translated_config[field_config][field_name]['allowed_values'] = translated_allowed_values
|
||||
|
||||
# Vertaal meta.consentRich en meta.aria*
|
||||
meta = field_data.get('meta')
|
||||
if isinstance(meta, dict):
|
||||
# consentRich
|
||||
if is_nonempty_str(meta.get('consentRich')):
|
||||
consent_ctx = (context if context else description_context) or ''
|
||||
consent_ctx = f"Consent rich text with inline tags. Keep tag names intact and translate only inner text. {consent_ctx}".strip()
|
||||
t = safe_translate(meta['consentRich'], consent_ctx)
|
||||
if t and tags_valid(meta['consentRich'], t):
|
||||
translated_config[field_config][field_name].setdefault('meta', {})['consentRich'] = t
|
||||
meta_consentRich_translated_count += 1
|
||||
else:
|
||||
if t and not tags_valid(meta['consentRich'], t) and current_event:
|
||||
src_counts = extract_tag_counts(meta['consentRich'])
|
||||
dst_counts = extract_tag_counts(t)
|
||||
current_event.log_error('inline_tags_validation_failed', {
|
||||
'tenant_id': tenant_id,
|
||||
'config_type': config_type,
|
||||
'config_version': config_version,
|
||||
'field_config': field_config,
|
||||
'field_name': field_name,
|
||||
'target_language': target_language,
|
||||
'source_tag_counts': src_counts,
|
||||
'translated_tag_counts': dst_counts
|
||||
})
|
||||
meta_inline_tags_invalid_after_translation_count += 1
|
||||
# fallback: keep original (already in deep copy)
|
||||
# aria*
|
||||
for k, v in list(meta.items()):
|
||||
if isinstance(k, str) and k.startswith('aria') and is_nonempty_str(v):
|
||||
aria_ctx = (context if context else description_context) or ''
|
||||
aria_ctx = f"ARIA label for accessibility. Short, imperative, descriptive. Form '{config_type} {config_version}', field '{field_name}'. {aria_ctx}".strip()
|
||||
t2 = safe_translate(v, aria_ctx)
|
||||
if t2:
|
||||
translated_config[field_config][field_name].setdefault('meta', {})[k] = t2
|
||||
meta_aria_translated_count += 1
|
||||
|
||||
return translated_config
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -9,8 +9,11 @@ content: >
|
||||
|
||||
'{context}'
|
||||
|
||||
Do not translate text in between double square brackets, as these are names or terms that need to remain intact.
|
||||
Remove the triple quotes in your translation!
|
||||
These are best practices you should follow:
|
||||
|
||||
- Do not translate text in between double square brackets, as these are names or terms that need to remain intact. Remove the square brackets in the translation!
|
||||
- We use inline tags (Custom HTML/XML-like tags). Ensure the tags themself are not translated and remain intact in the translation. The text inbetween the tags should be translated. e.g. "<terms_and_conditions>Terms & Conditions</terms_and_conditions>" translates in Dutch to <terms_and_conditions>Gebruiksvoorwaarden</terms_and_conditions>
|
||||
- Remove the triple quotes in your translation!
|
||||
|
||||
I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer
|
||||
without further interpretation. If more than one option is available, present me with the most probable one.
|
||||
|
||||
@@ -6,8 +6,11 @@ content: >
|
||||
|
||||
into '{target_language}'.
|
||||
|
||||
Do not translate text in between double square brackets, as these are names or terms that need to remain intact.
|
||||
Remove the triple quotes in your translation!
|
||||
These are best practices you should follow:
|
||||
|
||||
- Do not translate text in between double square brackets, as these are names or terms that need to remain intact. Remove the square brackets in the translation!
|
||||
- We use inline tags (Custom HTML/XML-like tags). Ensure the tags themself are not translated and remain intact in the translation. The text inbetween the tags should be translated. e.g. "<terms_and_conditions>Terms & Conditions</terms_and_conditions>" translates in Dutch to <terms_and_conditions>Gebruiksvoorwaarden</terms_and_conditions>
|
||||
- Remove the triple quotes in your translation!
|
||||
|
||||
I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer
|
||||
without further interpretation. If more than one option is available, present me with the most probable one.
|
||||
|
||||
@@ -24,6 +24,11 @@ fields:
|
||||
type: "boolean"
|
||||
description: "Consent"
|
||||
required: true
|
||||
meta:
|
||||
kind: "consent"
|
||||
consentRich: "Ik Agree with the <terms>Terms and Conditions</terms> and the <privacy>Privacy Statement</privacy> of Ask Eve AI"
|
||||
ariaPrivacy: "Open privacyverklaring in a modal dialog"
|
||||
ariaTerms: "Open algemene voorwaarden in a modal dialog"
|
||||
metadata:
|
||||
author: "Josako"
|
||||
date_added: "2025-07-29"
|
||||
|
||||
@@ -5,6 +5,17 @@ All notable changes to EveAI will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.1.1-alfa]
|
||||
|
||||
### Fixed
|
||||
- TRA-76 - Send Button color changes implemented
|
||||
- TRA-72 - Translation of privacy statement and T&C
|
||||
- TRA-73 - Strange characters in Tenant Make Name
|
||||
- TRA-77 - Adapted Scroll behavior for Chat Client in Message History
|
||||
|
||||
### Security
|
||||
- In case of vulnerabilities.
|
||||
|
||||
## [3.1.0-alfa]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -101,6 +101,19 @@ erDiagram
|
||||
int updated_by FK
|
||||
}
|
||||
|
||||
EVE_AI_DATA_CAPSULE {
|
||||
int id PK
|
||||
int chat_session_id FK
|
||||
string type
|
||||
string type_version
|
||||
jsonb configuration
|
||||
jsonb data
|
||||
datetime created_at
|
||||
int created_by FK
|
||||
datetime updated_at
|
||||
int updated_by FK
|
||||
}
|
||||
|
||||
DISPATCHER {
|
||||
int id PK
|
||||
string name
|
||||
@@ -188,24 +201,28 @@ erDiagram
|
||||
%% Main conversation flow
|
||||
USER ||--o{ CHAT_SESSION : "has many"
|
||||
CHAT_SESSION ||--o{ INTERACTION : "has many"
|
||||
CHAT_SESSION ||--o{ EVE_AI_DATA_CAPSULE : "has many"
|
||||
SPECIALIST ||--o{ INTERACTION : "processes"
|
||||
|
||||
|
||||
%% Specialist composition (EveAI components)
|
||||
SPECIALIST ||--o{ EVE_AI_AGENT : "has many"
|
||||
SPECIALIST ||--o{ EVE_AI_TASK : "has many"
|
||||
SPECIALIST ||--o{ EVE_AI_TOOL : "has many"
|
||||
|
||||
|
||||
%% Specialist connections
|
||||
SPECIALIST ||--o{ SPECIALIST_RETRIEVER : "uses retrievers"
|
||||
RETRIEVER ||--o{ SPECIALIST_RETRIEVER : "used by specialists"
|
||||
|
||||
|
||||
SPECIALIST ||--o{ SPECIALIST_DISPATCHER : "uses dispatchers"
|
||||
DISPATCHER ||--o{ SPECIALIST_DISPATCHER : "used by specialists"
|
||||
|
||||
|
||||
%% Interaction results
|
||||
INTERACTION ||--o{ INTERACTION_EMBEDDING : "references embeddings"
|
||||
EMBEDDING ||--o{ INTERACTION_EMBEDDING : "used in interactions"
|
||||
|
||||
|
||||
%% Magic links for specialist access
|
||||
SPECIALIST ||--o{ SPECIALIST_MAGIC_LINK : "has magic links"
|
||||
TENANT_MAKE ||--o{ SPECIALIST_MAGIC_LINK : "branded links"
|
||||
TENANT_MAKE ||--o{ SPECIALIST_MAGIC_LINK : "branded links"
|
||||
|
||||
%% User relationships for audit trails
|
||||
USER ||--o{ EVE_AI_DATA_CAPSULE : "created/updated by"
|
||||
@@ -1,45 +1,3 @@
|
||||
|
||||
/* Chat App Container Layout */
|
||||
.chat-app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0; /* Belangrijk voor flexbox overflow */
|
||||
padding: 20px; /* Algemene padding voor alle kanten */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Gemeenschappelijke container voor consistente breedte */
|
||||
.chat-component-container {
|
||||
width: 100%;
|
||||
max-width: 1000px; /* Optimale breedte */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1; /* Neemt beschikbare verticale ruimte in */
|
||||
}
|
||||
|
||||
/* Message Area - neemt alle beschikbare ruimte */
|
||||
.chat-messages-area {
|
||||
flex: 1; /* Neemt alle beschikbare ruimte */
|
||||
overflow: hidden; /* Voorkomt dat het groter wordt dan container */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Belangrijk voor nested flexbox */
|
||||
margin-bottom: 20px; /* Ruimte tussen messages en input */
|
||||
border-radius: 15px;
|
||||
background: var(--history-background);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 1000px; /* Optimale breedte */
|
||||
margin-left: auto;
|
||||
margin-right: auto; /* Horizontaal centreren */
|
||||
align-self: center; /* Extra centrering in flexbox context */
|
||||
}
|
||||
|
||||
/* Chat Input - altijd onderaan */
|
||||
.chat-input-area {
|
||||
flex: none; /* Neemt alleen benodigde ruimte */
|
||||
@@ -56,14 +14,6 @@
|
||||
align-self: center; /* Extra centrering in flexbox context */
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 10px; /* Ruimte voor scrollbar */
|
||||
margin-right: -10px; /* Compenseer voor scrollbar */
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Chat Input styling */
|
||||
|
||||
.chat-input {
|
||||
|
||||
@@ -99,15 +99,6 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Chat layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background-color: var(--sidebar-background);
|
||||
|
||||
@@ -549,20 +549,40 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.chat-app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-messages-area {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20px; /* Ruimte tussen messages en input */
|
||||
border-radius: 15px;
|
||||
background: var(--history-background);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 1000px; /* Optimale breedte */
|
||||
margin-left: auto;
|
||||
margin-right: auto; /* Horizontaal centreren */
|
||||
align-self: center; /* Extra centrering in flexbox context */
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<!-- EveAI Logo voor AI berichten - links boven, half buiten de bubbel -->
|
||||
<img
|
||||
v-if="message.sender === 'ai'"
|
||||
:src="staticUrl('assets/img/eveai_logo.svg')"
|
||||
:src="staticUrl('/assets/img/eveai_logo.svg')"
|
||||
alt="EveAI"
|
||||
class="ai-message-logo"
|
||||
/>
|
||||
@@ -105,7 +105,7 @@
|
||||
<!-- EveAI Logo voor AI berichten - links boven, half buiten de bubbel -->
|
||||
<img
|
||||
v-if="message.sender === 'ai'"
|
||||
:src="staticUrl('assets/img/eveai_logo.svg')"
|
||||
:src="staticUrl('/assets/img/eveai_logo.svg')"
|
||||
alt="EveAI"
|
||||
class="ai-message-logo"
|
||||
/>
|
||||
@@ -124,7 +124,7 @@
|
||||
<!-- EveAI Logo voor AI berichten - links boven, half buiten de bubbel -->
|
||||
<img
|
||||
v-if="message.sender === 'ai'"
|
||||
:src="staticUrl('assets/img/eveai_logo.svg')"
|
||||
:src="staticUrl('/assets/img/eveai_logo.svg')"
|
||||
alt="EveAI"
|
||||
class="ai-message-logo"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<span class="consent-rich-text">
|
||||
<template v-for="(node, idx) in nodes" :key="idx">
|
||||
<component
|
||||
v-if="node.type !== 'text'"
|
||||
:is="linkTag"
|
||||
:href="linkTag === 'a' ? '#' : undefined"
|
||||
class="consent-link"
|
||||
:aria-label="node.aria"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.prevent="emitClick(node.type)"
|
||||
@keydown.enter.prevent="emitClick(node.type)"
|
||||
@keydown.space.prevent="emitClick(node.type)"
|
||||
>{{ node.label }}</component>
|
||||
<span v-else>{{ node.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ConsentRichText',
|
||||
props: {
|
||||
template: { type: String, required: true },
|
||||
asButton: { type: Boolean, default: false },
|
||||
ariaPrivacy: { type: String, default: 'Open privacy statement in a dialog' },
|
||||
ariaTerms: { type: String, default: 'Open terms and conditions in a dialog' }
|
||||
},
|
||||
emits: ['open-privacy', 'open-terms'],
|
||||
computed: {
|
||||
linkTag() {
|
||||
return this.asButton ? 'button' : 'a';
|
||||
},
|
||||
nodes() {
|
||||
// Parse only allowed tags <privacy>...</privacy> and <terms>...</terms>
|
||||
const source = (this.template || '');
|
||||
|
||||
// 2) parse only allowed tags <privacy>...</privacy> and <terms>...</terms>
|
||||
const pattern = /<(privacy|terms)>([\s\S]*?)<\/\1>/gi;
|
||||
const out = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const [full, tag, label] = match;
|
||||
const start = match.index;
|
||||
if (start > lastIndex) {
|
||||
out.push({ type: 'text', text: source.slice(lastIndex, start) });
|
||||
}
|
||||
out.push({
|
||||
type: tag, // 'privacy' | 'terms'
|
||||
label: (label || '').trim(),
|
||||
aria: tag === 'privacy' ? this.ariaPrivacy : this.ariaTerms
|
||||
});
|
||||
lastIndex = start + full.length;
|
||||
}
|
||||
if (lastIndex < source.length) {
|
||||
out.push({ type: 'text', text: source.slice(lastIndex) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emitClick(kind) {
|
||||
if (kind === 'privacy') this.$emit('open-privacy');
|
||||
if (kind === 'terms') this.$emit('open-terms');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.consent-link {
|
||||
color: #007bff;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.consent-link:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
.consent-link:focus {
|
||||
outline: 2px solid #007bff;
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
button.consent-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #007bff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -104,14 +104,16 @@
|
||||
>
|
||||
<!-- Regular checkbox label -->
|
||||
<span v-if="!isConsentField" class="checkbox-text">{{ field.name }}</span>
|
||||
<!-- Consent field with privacy and terms links -->
|
||||
<span v-else class="checkbox-text consent-text">
|
||||
{{ texts.consentPrefix }}
|
||||
<a href="#" @click="openPrivacyModal" class="consent-link">{{ texts.privacyLink }}</a>
|
||||
{{ texts.consentMiddle }}
|
||||
<a href="#" @click="openTermsModal" class="consent-link">{{ texts.termsLink }}</a>
|
||||
{{ texts.consentSuffix }}
|
||||
</span>
|
||||
<!-- Consent field with privacy and terms links (rich, multilingual) -->
|
||||
<ConsentRichText
|
||||
v-else
|
||||
class="checkbox-text consent-text"
|
||||
:template="texts.consentRich"
|
||||
:aria-privacy="texts.ariaPrivacy || 'Open privacy statement in a dialog'"
|
||||
:aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'"
|
||||
@open-privacy="openPrivacyModal"
|
||||
@open-terms="openTermsModal"
|
||||
/>
|
||||
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -180,9 +182,11 @@
|
||||
|
||||
<script>
|
||||
import { useComponentTranslations } from '../js/services/LanguageProvider.js';
|
||||
import ConsentRichText from './ConsentRichText.vue';
|
||||
|
||||
export default {
|
||||
name: 'FormField',
|
||||
components: { ConsentRichText },
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -201,13 +205,11 @@ export default {
|
||||
},
|
||||
emits: ['update:modelValue', 'open-privacy-modal', 'open-terms-modal', 'keydown-enter'],
|
||||
setup() {
|
||||
// Consent text constants (English base)
|
||||
// Consent text constants (English base) - rich template
|
||||
const consentTexts = {
|
||||
consentPrefix: "I agree with the",
|
||||
consentMiddle: "and",
|
||||
consentSuffix: "of AskEveAI",
|
||||
privacyLink: "privacy statement",
|
||||
termsLink: "terms and conditions"
|
||||
consentRich: "I agree with the <privacy>privacy statement</privacy> and the <terms>terms and conditions</terms>",
|
||||
ariaPrivacy: 'Open privacy statement in a dialog',
|
||||
ariaTerms: 'Open terms and conditions in a dialog'
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -230,24 +232,36 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
texts() {
|
||||
// Robust consent texts that always return valid values
|
||||
// Use translated texts if available and valid, otherwise use fallback
|
||||
if (this.translatedTexts && typeof this.translatedTexts === 'object') {
|
||||
const translated = this.translatedTexts;
|
||||
// Check if translated texts have all required properties
|
||||
if (translated.consentPrefix && translated.consentMiddle && translated.consentSuffix &&
|
||||
translated.privacyLink && translated.termsLink) {
|
||||
return translated;
|
||||
}
|
||||
// Validate that consentRich exists and includes both required tags; otherwise fallback to English base
|
||||
const hasValidRich = (t) => t && typeof t.consentRich === 'string'
|
||||
&& /<privacy>[\s\S]*?<\/privacy>/.test(t.consentRich)
|
||||
&& /<terms>[\s\S]*?<\/terms>/.test(t.consentRich);
|
||||
|
||||
// 1) Prefer backend-provided rich string on the field's meta (already localized)
|
||||
const meta = this.field && this.field.meta ? this.field.meta : (this.field.i18n || null);
|
||||
if (meta && typeof meta.consentRich === 'string' && hasValidRich(meta)) {
|
||||
return {
|
||||
consentRich: meta.consentRich,
|
||||
ariaPrivacy: meta.ariaPrivacy || this.fallbackTexts.ariaPrivacy,
|
||||
ariaTerms: meta.ariaTerms || this.fallbackTexts.ariaTerms
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Otherwise, use client-side translated texts if available and valid
|
||||
if (this.translatedTexts && typeof this.translatedTexts === 'object' && hasValidRich(this.translatedTexts)) {
|
||||
return this.translatedTexts;
|
||||
}
|
||||
|
||||
// Fallback to English texts
|
||||
return this.fallbackTexts || {
|
||||
consentPrefix: "I agree with the",
|
||||
consentMiddle: "and",
|
||||
consentSuffix: "of AskEveAI",
|
||||
privacyLink: "privacy statement",
|
||||
termsLink: "terms and conditions"
|
||||
// 3) Fallback to English texts (rich template)
|
||||
if (this.fallbackTexts && hasValidRich(this.fallbackTexts)) {
|
||||
return this.fallbackTexts;
|
||||
}
|
||||
|
||||
// 4) Ultimate fallback (should not happen): provide a safe default
|
||||
return {
|
||||
consentRich: "I agree with the <privacy>privacy statement</privacy> and the <terms>terms and conditions</terms>",
|
||||
ariaPrivacy: 'Open privacy statement in a dialog',
|
||||
ariaTerms: 'Open terms and conditions in a dialog'
|
||||
};
|
||||
},
|
||||
value: {
|
||||
@@ -317,12 +331,10 @@ export default {
|
||||
this.value = file;
|
||||
}
|
||||
},
|
||||
openPrivacyModal(event) {
|
||||
event.preventDefault();
|
||||
openPrivacyModal() {
|
||||
this.$emit('open-privacy-modal');
|
||||
},
|
||||
openTermsModal(event) {
|
||||
event.preventDefault();
|
||||
openTermsModal() {
|
||||
this.$emit('open-terms-modal');
|
||||
},
|
||||
|
||||
|
||||
@@ -9,13 +9,6 @@
|
||||
|
||||
<!-- Messages wrapper for bottom alignment -->
|
||||
<div class="messages-wrapper">
|
||||
<!-- Empty state (only show when no messages) -->
|
||||
<div v-if="normalMessages.length === 0" class="empty-state">
|
||||
<div class="empty-icon">💬</div>
|
||||
<div class="empty-text">Nog geen berichten</div>
|
||||
<div class="empty-subtext">Start een gesprek door een bericht te typen!</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal message list (excluding temporarily positioned AI messages) -->
|
||||
<template v-if="normalMessages.length > 0">
|
||||
<!-- Messages -->
|
||||
@@ -87,7 +80,8 @@ export default {
|
||||
isAtBottom: true,
|
||||
showScrollButton: false,
|
||||
unreadCount: 0,
|
||||
languageChangeHandler: null
|
||||
languageChangeHandler: null,
|
||||
_prevSnapshot: { length: 0, firstId: null, lastId: null },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -98,29 +92,41 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
messages: {
|
||||
handler(newMessages, oldMessages) {
|
||||
const hasNewMessages = newMessages.length > (oldMessages?.length || 0);
|
||||
|
||||
// Always auto-scroll when new messages are added (regardless of current scroll position)
|
||||
if (this.autoScroll && hasNewMessages) {
|
||||
// Double $nextTick for better DOM update synchronization
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom(true);
|
||||
});
|
||||
});
|
||||
async handler(newMessages, oldMessages) {
|
||||
const prev = this._prevSnapshot || { length: 0, firstId: null, lastId: null };
|
||||
const curr = this.makeSnapshot(newMessages);
|
||||
const container = this.$refs.messagesContainer;
|
||||
|
||||
const lengthIncreased = curr.length > prev.length;
|
||||
const lengthDecreased = curr.length < prev.length; // reset/trim
|
||||
const appended = lengthIncreased && curr.lastId !== prev.lastId;
|
||||
const prepended = lengthIncreased && curr.firstId !== prev.firstId && curr.lastId === prev.lastId;
|
||||
const mutatedLastSameLength = curr.length === prev.length && curr.lastId === prev.lastId;
|
||||
|
||||
if (prepended && container) {
|
||||
// Load-more bovenaan: positie behouden
|
||||
const before = container.scrollHeight;
|
||||
await this.nextFrame();
|
||||
await this.nextFrame();
|
||||
this.preserveScrollOnPrepend(before);
|
||||
} else if (appended) {
|
||||
// Nieuw bericht onderaan: altijd naar beneden (eis)
|
||||
await this.scrollToBottom(true, { smooth: true, retries: 2 });
|
||||
} else if (mutatedLastSameLength) {
|
||||
// Laatste bericht groeit (AI streaming/media). Alleen sticky als we al onderaan waren of autoScroll actief is
|
||||
if (this.autoScroll || this.isAtBottom) {
|
||||
await this.scrollToBottom(false, { smooth: true, retries: 2 });
|
||||
}
|
||||
} else if (lengthDecreased && this.autoScroll) {
|
||||
// Lijst verkort (reset): terug naar onderen
|
||||
await this.scrollToBottom(true, { smooth: false, retries: 2 });
|
||||
}
|
||||
|
||||
this._prevSnapshot = curr;
|
||||
},
|
||||
deep: true,
|
||||
immediate: false
|
||||
immediate: false,
|
||||
},
|
||||
isTyping(newVal) {
|
||||
if (newVal && this.autoScroll) {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// Maak een benoemde handler voor betere cleanup
|
||||
@@ -135,25 +141,36 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.setupScrollListener();
|
||||
|
||||
// Initial scroll to bottom
|
||||
|
||||
if (this.autoScroll) {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
this.$nextTick(() => this.scrollToBottom(true, { smooth: false, retries: 2 }));
|
||||
}
|
||||
|
||||
this._onResize = this.debounce(() => {
|
||||
if (this.autoScroll || this.isAtBottom) {
|
||||
this.scrollToBottom(false, { smooth: true, retries: 1 });
|
||||
}
|
||||
}, 150);
|
||||
window.addEventListener('resize', this._onResize);
|
||||
|
||||
const wrapper = this.$el.querySelector('.messages-wrapper');
|
||||
if (window.ResizeObserver && wrapper) {
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
if (this.autoScroll || this.isAtBottom) {
|
||||
this.scrollToBottom(false, { smooth: true, retries: 1 });
|
||||
}
|
||||
});
|
||||
this._resizeObserver.observe(wrapper);
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Cleanup scroll listener
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
// Cleanup language change listener
|
||||
if (container) container.removeEventListener('scroll', this.handleScroll);
|
||||
if (this.languageChangeHandler) {
|
||||
document.removeEventListener('language-changed', this.languageChangeHandler);
|
||||
document.removeEventListener('language-changed', this.languageChangeHandler);
|
||||
}
|
||||
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||
if (this._resizeObserver) this._resizeObserver.disconnect();
|
||||
},
|
||||
methods: {
|
||||
async handleLanguageChange(event) {
|
||||
@@ -197,17 +214,52 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
scrollToBottom(force = false) {
|
||||
async nextFrame() {
|
||||
await this.$nextTick();
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
},
|
||||
|
||||
async scrollToBottom(force = false, { smooth = true, retries = 2 } = {}) {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
// Use requestAnimationFrame for better timing
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
this.isAtBottom = true;
|
||||
this.showScrollButton = false;
|
||||
this.unreadCount = 0;
|
||||
});
|
||||
if (!container) return;
|
||||
|
||||
const doScroll = (instant = false) => {
|
||||
const behavior = instant ? 'auto' : (smooth ? 'smooth' : 'auto');
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
};
|
||||
|
||||
// Wacht enkele frames om late layout/afbeeldingen te vangen
|
||||
for (let i = 0; i < retries; i++) {
|
||||
await this.nextFrame();
|
||||
}
|
||||
|
||||
// Probeer smooth naar onder
|
||||
doScroll(false);
|
||||
|
||||
// Forceer desnoods na nog een frame een harde correctie
|
||||
if (force) {
|
||||
await this.nextFrame();
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
this.isAtBottom = true;
|
||||
this.showScrollButton = false;
|
||||
this.unreadCount = 0;
|
||||
},
|
||||
|
||||
makeSnapshot(list) {
|
||||
const length = list.length;
|
||||
const firstId = length ? list[0].id : null;
|
||||
const lastId = length ? list[length - 1].id : null;
|
||||
return { length, firstId, lastId };
|
||||
},
|
||||
|
||||
preserveScrollOnPrepend(beforeHeight) {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (!container) return;
|
||||
const afterHeight = container.scrollHeight;
|
||||
const delta = afterHeight - beforeHeight;
|
||||
container.scrollTop = container.scrollTop + delta;
|
||||
},
|
||||
|
||||
setupScrollListener() {
|
||||
@@ -217,19 +269,21 @@ export default {
|
||||
container.addEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
|
||||
debounce(fn, wait = 150) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn.apply(this, args), wait);
|
||||
};
|
||||
},
|
||||
|
||||
handleScroll() {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (!container) return;
|
||||
|
||||
const threshold = 50; // Reduced threshold for better detection
|
||||
const threshold = 80; // was 50
|
||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
||||
|
||||
this.isAtBottom = isNearBottom;
|
||||
|
||||
// Load more messages when scrolled to top
|
||||
if (container.scrollTop === 0) {
|
||||
this.$emit('load-more');
|
||||
}
|
||||
if (container.scrollTop === 0) this.$emit('load-more');
|
||||
},
|
||||
|
||||
handleImageLoaded() {
|
||||
@@ -274,35 +328,39 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.message-history-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 20px; /* Interne padding voor MessageHistory */
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1000px; /* Optimale breedte */
|
||||
margin-left: auto;
|
||||
margin-right: auto; /* Horizontaal centreren */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0; /* Laat kinderen scrollen */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
/* overflow: hidden; // mag weg of blijven; met stap 1 clipt dit niet meer */
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* Bottom-aligned messages implementation */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-gutter: stable both-edges; /* houdt ruimte vrij voor scrollbar */
|
||||
padding-right: 0; /* haal de hack weg */
|
||||
margin-right: 0;
|
||||
|
||||
/* Belangrijk: haal min-height: 100% weg en vervang */
|
||||
/* min-height: 100%; */
|
||||
min-height: 0; /* toestaan dat het kind krimpt voor overflow */
|
||||
-webkit-overflow-scrolling: touch; /* betere iOS scroll */
|
||||
}
|
||||
|
||||
.messages-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 100%;
|
||||
gap: 10px; /* Space between messages */
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="progress-title">
|
||||
<!-- Evie working animatie tijdens processing -->
|
||||
<img v-if="isProcessing"
|
||||
:src="staticUrl('assets/img/evie_working.webp')"
|
||||
:src="staticUrl('/assets/img/evie_working.webp')"
|
||||
alt="Bezig met verwerken..."
|
||||
class="progress-icon working-animation">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<!-- Alleen Evie animatie voor "No Information" tijdens processing -->
|
||||
<div v-else-if="shouldShowProgressIconOnly" class="progress-icon-only">
|
||||
<img :src="staticUrl('assets/img/evie_working.webp')"
|
||||
<img :src="staticUrl('/assets/img/evie_working.webp')"
|
||||
alt="Bezig met verwerken..."
|
||||
class="working-animation-only">
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<script>
|
||||
// Definieer chatConfig voordat componenten worden geladen
|
||||
window.chatConfig = {
|
||||
explanation: `{{ customisation.sidebar_markdown|default('') }}`,
|
||||
progress_tracker_insights: `{{ customisation.progress_tracker_insights|default('No Information') }}`,
|
||||
form_title_display: `{{ customisation.form_title_display|default('Full Title') }}`,
|
||||
conversationId: '{{ conversation_id|default("default") }}',
|
||||
explanation: {{ customisation.sidebar_markdown|default('')|tojson }},
|
||||
progress_tracker_insights: {{ customisation.progress_tracker_insights|default('No Information')|tojson }},
|
||||
form_title_display: {{ customisation.form_title_display|default('Full Title')|tojson }},
|
||||
conversationId: {{ conversation_id|default('default')|tojson }},
|
||||
messages: {{ messages|tojson|safe }},
|
||||
settings: {
|
||||
maxMessageLength: {{ settings.max_message_length|default(2000) }},
|
||||
@@ -22,15 +22,15 @@
|
||||
allowReactions: {{ settings.allow_reactions|default('true')|lower }}
|
||||
},
|
||||
apiPrefix: '/chat-client/chat',
|
||||
language: '{{ session.magic_link.specialist_args.language|default("en") }}',
|
||||
language: {{ session.magic_link.specialist_args.language|default('en')|tojson }},
|
||||
supportedLanguageDetails: {{ config.SUPPORTED_LANGUAGE_DETAILS|tojson|safe }},
|
||||
allowedLanguages: {{ tenant_make.allowed_languages|tojson|safe }},
|
||||
tenantMake: {
|
||||
name: "{{ tenant_make.name|default('EveAI') }}",
|
||||
logo_url: "{{ tenant_make.logo_url|default('') }}"
|
||||
},
|
||||
tenantMake: {{ {
|
||||
'name': tenant_make.name or 'EveAI',
|
||||
'logo_url': tenant_make.logo_url or ''
|
||||
}|tojson|safe }},
|
||||
// Environment-aware static base provided by Flask's overridden url_for
|
||||
staticBase: '{{ static_url }}'
|
||||
staticBase: {{ static_url|tojson }}
|
||||
};
|
||||
|
||||
// Debug info om te controleren of chatConfig correct is ingesteld
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
try { globalThis.staticUrl = window.staticUrl; } catch (e) {}
|
||||
} else {
|
||||
// Prefer runtime chatConfig.staticBase; else fallback to server-provided base or default
|
||||
var serverStaticBase = '{{ static_url|default("") }}' || '';
|
||||
if (!serverStaticBase) { serverStaticBase = '{{ url_for("static", filename="") }}'; }
|
||||
var serverStaticBase = {{ static_url|default('')|tojson }} || '';
|
||||
if (!serverStaticBase) { serverStaticBase = {{ url_for('static', filename='')|tojson }}; }
|
||||
var base = (window.chatConfig && window.chatConfig.staticBase) ? window.chatConfig.staticBase : (serverStaticBase || '/static/');
|
||||
var normalizedBase = String(base).replace(/\/+$/, '/');
|
||||
window.staticUrl = function(path) {
|
||||
@@ -34,19 +34,19 @@
|
||||
window.chatConfig.supportedLanguages = [
|
||||
{% for lang_code in config.SUPPORTED_LANGUAGES %}
|
||||
{
|
||||
code: "{{ lang_code }}",
|
||||
name: "{{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['iso 639-1'] }}",
|
||||
flag: "{{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['flag'] }}"
|
||||
code: {{ lang_code|tojson }},
|
||||
name: {{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['iso 639-1']|tojson }},
|
||||
flag: {{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['flag']|tojson }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
// Voeg tenantMake toe aan chatConfig als die nog niet bestaat
|
||||
if (!window.chatConfig.tenantMake) {
|
||||
window.chatConfig.tenantMake = {
|
||||
name: "{{ tenant_make.name|default('EveAI') }}",
|
||||
logo_url: "{{ tenant_make.logo_url|default('') }}"
|
||||
};
|
||||
window.chatConfig.tenantMake = {{ {
|
||||
'name': tenant_make.name or 'EveAI',
|
||||
'logo_url': tenant_make.logo_url or ''
|
||||
}|tojson|safe }};
|
||||
}
|
||||
|
||||
console.log('Taalinstellingen toegevoegd aan chatConfig');
|
||||
|
||||
@@ -89,18 +89,19 @@
|
||||
|
||||
/* General Styles */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 75vh;
|
||||
/*max-height: 100vh;*/
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--chat-bg);
|
||||
font-family: var(--font-family); /* Apply the default font family */
|
||||
color: var(--font-color); /* Apply the default font color */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 75vh;
|
||||
min-height: 0;
|
||||
/*max-height: 100vh;*/
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--chat-bg);
|
||||
font-family: var(--font-family); /* Apply the default font family */
|
||||
color: var(--font-color); /* Apply the default font color */
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
|
||||
Reference in New Issue
Block a user