From a31ae0b3de8a27ace229422f7cf00cf992f539df Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 28 Jul 2026 20:24:19 +0200 Subject: [PATCH] Add comprehensive `GeneralSettings` component: refactor settings layout with structured cards, introduce new theme, font, and language pickers, enhance account and privacy management options with improved UX. --- common/messages/de.json | 18 + common/messages/fr.json | 18 + tests/e2e/web/pages/settingsPage.ts | 18 +- web/components/buttons/button.tsx | 8 +- .../connection-preferences-settings.tsx | 133 +++--- web/components/font-picker.tsx | 2 +- web/components/language/language-picker.tsx | 2 +- web/components/layout/tabs.tsx | 18 +- web/components/notifications.tsx | 19 +- .../profile/delete-account-survey-modal.tsx | 7 +- web/components/settings/general-settings.tsx | 404 ++++++++++++++++++ web/components/settings/settings-card.tsx | 123 ++++++ web/components/settings/theme-picker.tsx | 54 +++ .../widgets/choices-toggle-group.tsx | 16 +- web/pages/settings.tsx | 299 ++----------- 15 files changed, 818 insertions(+), 321 deletions(-) create mode 100644 web/components/settings/general-settings.tsx create mode 100644 web/components/settings/settings-card.tsx create mode 100644 web/components/settings/theme-picker.tsx diff --git a/common/messages/de.json b/common/messages/de.json index a4e56494..edda4fb0 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -1436,6 +1436,7 @@ "security.seo.title": "Sicherheit", "security.title": "Sicherheit", "send_message.button_label": "Nachricht", + "settings.account.card_description": "Wie Sie sich anmelden und wie wir Sie erreichen.", "settings.action.cancel": "Abbrechen", "settings.action.save": "Speichern", "settings.connection_preferences.description": "Kontrollieren Sie, wie andere sich mit Ihnen verbinden können.", @@ -1447,16 +1448,20 @@ "settings.connection_preferences.update_failed": "Fehler beim Aktualisieren der Einstellungen", "settings.connection_preferences.updated": "Einstellungen aktualisiert", "settings.danger_zone": "Gefahrenzone", + "settings.danger_zone.description": "Beim Löschen Ihres Kontos werden Profil, Nachrichten und Antworten entfernt. Das lässt sich nicht rückgängig machen.", + "settings.data_privacy.card_description": "Alles, was Compass über Sie speichert, gehört Ihnen.", "settings.data_privacy.description": "Laden Sie eine JSON-Datei mit allen Ihren Informationen herunter: Profil, Konto, Nachrichten, Kompatibilitätsantworten, markierte Profile, Stimmen, Empfehlungen, Such-Lesezeichen usw.", "settings.data_privacy.download": "Alle meine Daten herunterladen (JSON)", "settings.data_privacy.download.error": "Fehler beim Herunterladen Ihrer Daten. Bitte versuchen Sie es erneut.", "settings.data_privacy.download.success": "Ihr Daten-Export wurde heruntergeladen.", "settings.data_privacy.downloading": "Herunterladen...", + "settings.data_privacy.export": "Ihre Daten exportieren", "settings.data_privacy.title": "Daten & Datenschutz", "settings.delete.error": "Löschen des Kontos fehlgeschlagen.", "settings.delete.loading": "Konto wird gelöscht..", "settings.delete.success": "Ihr Konto wurde gelöscht.", "settings.delete_account": "Konto löschen", + "settings.delete_account.description": "Wir fragen Sie nach dem Grund, bevor etwas entfernt wird.", "settings.delete_confirm": "Möchten Sie Ihr Profil wirklich löschen? Dies kann nicht rückgängig gemacht werden.", "settings.email.change": "E-Mail-Adresse ändern", "settings.email.invalid": "Ungültige E-Mail-Adresse", @@ -1470,19 +1475,27 @@ "settings.email.send_verification": "Bestätigungs-E-Mail senden", "settings.email.sending": "Bestätigungs-E-Mail wird gesendet...", "settings.email.too_many_requests": "Sie können nicht mehr als eine E-Mail pro Minute anfordern. Bitte warten Sie, bevor Sie eine weitere Anfrage senden.", + "settings.email.unverified_short": "Nicht bestätigt", "settings.email.update_failed": "Aktualisierung der E-Mail-Adresse fehlgeschlagen", "settings.email.updated_success": "E-Mail-Adresse erfolgreich aktualisiert", "settings.email.verification_failed": "Senden der Bestätigungs-E-Mail fehlgeschlagen.", "settings.email.verification_sent": "Bestätigungs-E-Mail gesendet – prüfen Sie Ihren Posteingang und Spam-Ordner.", "settings.email.verified": "E-Mail bestätigt ✔️", + "settings.email.verified_short": "Bestätigt", + "settings.email.verify": "E-Mail-Adresse bestätigen", + "settings.email.verify_description": "Einige Funktionen bleiben gesperrt, bis Ihre Adresse bestätigt ist.", "settings.general.account": "Konto", "settings.general.email": "E-Mail", "settings.general.font": "Schriftart", + "settings.general.font_description": "Atkinson Hyperlegible ist auf leichtere Lesbarkeit ausgelegt.", "settings.general.language": "Sprache", + "settings.general.language_description": "Die Sprache der Benutzeroberfläche.", "settings.general.measurement": "Maßeinheitensystem", + "settings.general.measurement_description": "Wird für Größen und Entfernungen verwendet.", "settings.general.password": "Passwort", "settings.general.people": "Personen", "settings.general.theme": "Design", + "settings.general.theme_description": "„Automatisch“ folgt der Hell-/Dunkel-Einstellung Ihres Geräts.", "settings.hidden_profiles.description": "Diese Personen erscheinen nicht in Ihren Suchergebnissen.", "settings.hidden_profiles.empty": "Sie haben keine Profile ausgeblendet.", "settings.hidden_profiles.load_error": "Fehler beim Laden ausgeblendeter Profile.", @@ -1495,7 +1508,12 @@ "settings.hidden_profiles.unhiding": "Einblenden...", "settings.measurement.imperial": "Imperial", "settings.measurement.metric": "Metrisch", + "settings.password.description": "Wir senden Ihnen einen Link, um ein neues zu wählen.", "settings.password.send_reset": "Passwort-Zurücksetzungs-E-Mail senden", + "settings.people.card_description": "Profile, die Sie nicht sehen möchten.", + "settings.saved": "Gespeichert", + "settings.section.appearance": "Darstellung & Sprache", + "settings.section.appearance_description": "Wie Compass auf diesem Gerät aussieht und sich liest.", "settings.tabs.about": "Über", "settings.tabs.general": "Allgemein", "settings.tabs.notifications": "Benachrichtigungen", diff --git a/common/messages/fr.json b/common/messages/fr.json index 52428752..902ed14a 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -1435,6 +1435,7 @@ "security.seo.title": "Sécurité", "security.title": "Sécurité", "send_message.button_label": "Contacter", + "settings.account.card_description": "Comment vous vous connectez et comment nous vous contactons.", "settings.action.cancel": "Annuler", "settings.action.save": "Enregistrer", "settings.connection_preferences.description": "Contrôlez comment les autres peuvent se connecter avec vous.", @@ -1446,16 +1447,20 @@ "settings.connection_preferences.update_failed": "Échec de la mise à jour des préférences", "settings.connection_preferences.updated": "Préférences mises à jour", "settings.danger_zone": "Zone dangereuse", + "settings.danger_zone.description": "La suppression de votre compte efface votre profil, vos messages et vos réponses. Cette action est irréversible.", + "settings.data_privacy.card_description": "Tout ce que Compass conserve à votre sujet vous appartient.", "settings.data_privacy.description": "Téléchargez un fichier JSON contenant toutes vos informations : profil, compte, messages, réponses de compatibilité, profils en favoris, votes, recommandations, recherches enregistrées, etc.", "settings.data_privacy.download": "Télécharger toutes mes données (JSON)", "settings.data_privacy.download.error": "Échec du téléchargement de vos données. Veuillez réessayer.", "settings.data_privacy.download.success": "Votre export de données a été téléchargé.", "settings.data_privacy.downloading": "Téléchargement...", + "settings.data_privacy.export": "Exporter vos données", "settings.data_privacy.title": "Données & Confidentialité", "settings.delete.error": "Échec de la suppression du compte.", "settings.delete.loading": "Suppression du compte..", "settings.delete.success": "Votre compte a été supprimé.", "settings.delete_account": "Supprimer le compte", + "settings.delete_account.description": "Nous vous demanderons pourquoi avant de supprimer quoi que ce soit.", "settings.delete_confirm": "Êtes-vous sûr de vouloir supprimer votre profil ? Cela ne peut pas être annulé.", "settings.email.change": "Modifier l'adresse e‑mail", "settings.email.invalid": "Adresse e‑mail invalide", @@ -1469,19 +1474,27 @@ "settings.email.send_verification": "Envoyer un e-mail de vérification", "settings.email.sending": "Envoi de l'e-mail de vérification...", "settings.email.too_many_requests": "Vous ne pouvez pas demander plus d'un e-mail par minute. Veuillez attendre avant d'envoyer une autre demande.", + "settings.email.unverified_short": "Non vérifiée", "settings.email.update_failed": "Échec de la mise à jour de l'adresse e‑mail", "settings.email.updated_success": "Adresse e‑mail mise à jour avec succès", "settings.email.verification_failed": "Échec de l'envoi de l'e-mail de vérification.", "settings.email.verification_sent": "E-mail de vérification envoyé — vérifiez votre boîte de réception et les spams.", "settings.email.verified": "E-mail vérifié ✔️", + "settings.email.verified_short": "Vérifiée", + "settings.email.verify": "Vérifier votre e-mail", + "settings.email.verify_description": "Certaines fonctionnalités restent bloquées tant que votre adresse n’est pas confirmée.", "settings.general.account": "Compte", "settings.general.email": "E-mail", "settings.general.font": "Police", + "settings.general.font_description": "Atkinson Hyperlegible est conçue pour une lecture plus facile.", "settings.general.language": "Langue", + "settings.general.language_description": "La langue de l’interface.", "settings.general.measurement": "Système de mesure", + "settings.general.measurement_description": "Utilisé pour les tailles et les distances.", "settings.general.password": "Mot de passe", "settings.general.people": "Personnes", "settings.general.theme": "Thème", + "settings.general.theme_description": "« Auto » suit le réglage clair ou sombre de votre appareil.", "settings.hidden_profiles.description": "Ces personnes n'apparaissent pas dans vos résultats de recherche.", "settings.hidden_profiles.empty": "Vous n'avez masqué aucun profil.", "settings.hidden_profiles.load_error": "Échec du chargement des profils masqués.", @@ -1494,7 +1507,12 @@ "settings.hidden_profiles.unhiding": "Affichage...", "settings.measurement.imperial": "Impérial", "settings.measurement.metric": "Métrique", + "settings.password.description": "Nous vous enverrons un lien pour en choisir un nouveau.", "settings.password.send_reset": "Envoyer un e‑mail de réinitialisation de mot de passe", + "settings.people.card_description": "Les profils que vous avez choisi de ne pas voir.", + "settings.saved": "Enregistré", + "settings.section.appearance": "Apparence et langue", + "settings.section.appearance_description": "L’apparence et la lisibilité de Compass sur cet appareil.", "settings.tabs.about": "À propos", "settings.tabs.general": "Général", "settings.tabs.notifications": "Notifications", diff --git a/tests/e2e/web/pages/settingsPage.ts b/tests/e2e/web/pages/settingsPage.ts index 5b71eda3..d5499653 100644 --- a/tests/e2e/web/pages/settingsPage.ts +++ b/tests/e2e/web/pages/settingsPage.ts @@ -58,9 +58,25 @@ export class SettingsPage { await this.measurementSystemToggle.click() } + /** + * The theme control is three radio pills (Light / Dark / Auto), not the single cycling button it + * used to be, so "toggle" means advancing to the next option rather than clicking one element. + */ async toggleDisplayTheme() { await expect(this.themeToggle).toBeVisible() - await this.themeToggle.click() + const options = this.themeToggle.getByRole('radio') + const count = await options.count() + let checked = 0 + for (let i = 0; i < count; i++) { + if ((await options.nth(i).getAttribute('aria-checked')) === 'true') checked = i + } + await options.nth((checked + 1) % count).click() + } + + async setDisplayTheme(theme: 'light' | 'dark' | 'auto') { + const option = this.page.getByTestId(`settings-theme-${theme}`) + await expect(option).toBeVisible() + await option.click() } async setFont(font: FontsTuple) { diff --git a/web/components/buttons/button.tsx b/web/components/buttons/button.tsx index 4fcf0a20..f48f4634 100644 --- a/web/components/buttons/button.tsx +++ b/web/components/buttons/button.tsx @@ -49,7 +49,13 @@ export function buttonClass(size: SizeType, color: ColorType) { color === 'green' && [solid, 'bg-teal-500 hover:bg-teal-600'], color === 'green-outline' && [outline, 'text-teal-500 hover:bg-teal-500'], color === 'red' && [solid, 'bg-red-500 hover:bg-red-600'], - color === 'red-outline' && [outline, 'text-scarlet-500 hover:bg-scarlet-500'], + // `scarlet` is this theme's *grey* "no" token (`--color-no-500` is 140 140 140), so the red + // outline button rendered as a grey one — indistinguishable from `gray-outline` beside it. The + // red ramp is the actual red. Hover also flips the label to white, or red-on-red was unreadable. + color === 'red-outline' && [ + outline, + 'text-red-500 !ring-red-500/40 hover:bg-red-500 hover:text-white', + ], color === 'yellow' && [solid, 'bg-yellow-400 hover:bg-yellow-500'], color === 'yellow-outline' && [outline, 'text-yellow-500 hover:bg-yellow-500'], color === 'blue' && [solid, 'bg-blue-400 hover:bg-blue-500'], diff --git a/web/components/connection-preferences-settings.tsx b/web/components/connection-preferences-settings.tsx index 3da3a643..77a4cfae 100644 --- a/web/components/connection-preferences-settings.tsx +++ b/web/components/connection-preferences-settings.tsx @@ -1,12 +1,19 @@ -import {useState} from 'react' +import {CheckIcon} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import {useEffect, useState} from 'react' import toast from 'react-hot-toast' -import {Col} from 'web/components/layout/col' import {Row} from 'web/components/layout/row' +import {SettingsRow} from 'web/components/settings/settings-card' import {SwitchSetting} from 'web/components/switch-setting' import {useProfile} from 'web/hooks/use-profile' import {updateProfile} from 'web/lib/api' import {useT} from 'web/lib/locale' +/** + * Renders as bare `SettingsRow`s so it can sit inside the Connections card on the settings page — + * the card header now carries the title and the "control how others can connect with you" line that + * this component used to print itself. + */ export function ConnectionPreferencesSettings() { const t = useT() const profile = useProfile() @@ -19,12 +26,17 @@ export function ConnectionPreferencesSettings() { ) const [isUpdating, setIsUpdating] = useState(false) + // A fresh object per save, not just the field name: flipping the same toggle twice must show the + // confirmation twice, and a state value that compares equal to itself would not re-fire the effect + // that shows it. + const [saved, setSaved] = useState<{field: string; at: number} | null>(null) const handleUpdate = async (field: string, value: boolean) => { setIsUpdating(true) try { await updateProfile({[field]: value}) // toast.success(t('settings.connection_preferences.updated', 'Preferences updated')) + setSaved({field, at: Date.now()}) } catch (error) { console.error('Error updating preferences:', error) toast.error( @@ -46,55 +58,76 @@ export function ConnectionPreferencesSettings() { } return ( - -
- {t( - 'settings.connection_preferences.description', - 'Control how others can connect with you.', + <> + + > + + + + + - -
-
- {t('settings.connection_preferences.direct_messaging', 'Direct Messaging')} -
-
- {t( - 'settings.connection_preferences.dm_description', - 'Let anyone start a conversation with you immediately.', - )} -
-
- -
- - -
-
- {t('settings.connection_preferences.interest_indicator', 'Private interest signals')} -
-
- {t( - 'settings.connection_preferences.indicator_description', - 'Allow people to privately signal interest. You are only notified if the interest is mutual.', - )} -
-
- -
- + + + + + + + + ) +} + +/** + * These toggles write straight through with no confirmation of any kind — the success toast has been + * commented out for a while — so flipping one and being told nothing is indistinguishable from + * flipping one and having it fail silently. A checkmark that fades after two seconds is the smallest + * thing that closes that loop without a toast covering the page on every tap. + */ +function SavedFlash(props: {field: string; saved: {field: string; at: number} | null}) { + const {field, saved} = props + const [visible, setVisible] = useState(false) + const t = useT() + + useEffect(() => { + if (saved?.field !== field) return + setVisible(true) + const timer = setTimeout(() => setVisible(false), 2000) + return () => clearTimeout(timer) + }, [saved, field]) + + return ( + + + {t('settings.saved', 'Saved')} + ) } diff --git a/web/components/font-picker.tsx b/web/components/font-picker.tsx index 6412ed45..8fe3a4aa 100644 --- a/web/components/font-picker.tsx +++ b/web/components/font-picker.tsx @@ -24,7 +24,7 @@ export function FontPicker(props: {className?: string} = {}) { value={font} onChange={(e: React.ChangeEvent) => setFont(e.target.value as FontOption)} className={clsx( - 'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50', + 'rounded-lg border border-canvas-300 bg-canvas-50 px-3 py-2 text-sm text-ink-1000 transition-colors hover:border-primary-400 focus:border-primary-500 focus:outline-none', className, )} > diff --git a/web/components/language/language-picker.tsx b/web/components/language/language-picker.tsx index ebf2e457..ab269fc3 100644 --- a/web/components/language/language-picker.tsx +++ b/web/components/language/language-picker.tsx @@ -15,7 +15,7 @@ export function LanguagePicker(props: {className?: string} = {}) { value={locale} onChange={(e: React.ChangeEvent) => setLocale(e.target.value)} className={clsx( - 'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50', + 'rounded-lg border border-canvas-300 bg-canvas-50 px-3 py-2 text-sm text-ink-1000 transition-colors hover:border-primary-400 focus:border-primary-500 focus:outline-none', className, )} > diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 5719e28b..b93ccfe1 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -168,8 +168,18 @@ export function ControlledTabs(props: TabProps & {activeIndex: number}) { ) } -export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) { - const {defaultIndex, onClick, ...rest} = props +export function UncontrolledTabs( + props: TabProps & { + defaultIndex?: number + /** + * Fires with the active index, including the one restored from cache on mount — `onClick` only + * ever reports a change the user just made, which is not enough for a caller that has to render + * something else alongside the active tab (the settings page's section index). + */ + onActiveIndexChange?: (index: number) => void + }, +) { + const {defaultIndex, onClick, onActiveIndexChange, ...rest} = props const [activeIndex, setActiveIndex] = usePersistentInMemoryState( defaultIndex ?? 0, `tab-${props.trackingName}-${props.name ?? props.tabs[0]?.title}`, @@ -185,6 +195,10 @@ export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) { } }, []) + useEffect(() => { + onActiveIndexChange?.(activeIndex) + }, [activeIndex]) + return ( - -
+ +

{t('notifications.heading', 'Where do you want to be notified when someone')}

- + {socialNotificationTypes.map(renderOption)} - -
+ +

{t('notifications.section.other', 'Other updates')}

- {otherNotificationTypes.map(renderOption)} + + {otherNotificationTypes.map(renderOption)} + {/* Master switch: its own card so it doesn't read as just another "other update". The toggles still line up with the columns above, so it needs no repeated header. */} - + {renderOption({ type: 'opt_out_all', question: t('notifications.question.opt_out_all', 'Opt out of all notifications?'), @@ -144,7 +147,7 @@ const OPTION_GRID = 'grid grid-cols-[minmax(0,1fr)_4.5rem_4.5rem] items-center g const DestinationHeader = () => { const t = useT() return ( -
+
{SHOWN_NOTIFICATION_DESTINATION_TYPES.map((destinationType) => ( ( + {(user) => } +) + +/** Cards enter in sequence rather than all at once, so the eye is led down the column. */ +const enter = (index: number) => ({animationDelay: `${index * 60}ms`}) + +const LoadedGeneralSettings = (props: {privateUser: PrivateUser}) => { + const {privateUser} = props + + const [isChangingEmail, setIsChangingEmail] = useState(false) + const [showHiddenProfiles, setShowHiddenProfiles] = useState(false) + const { + register, + handleSubmit, + formState: {errors}, + reset, + } = useForm<{newEmail: string}>() + const t = useT() + + const firebaseUser = useFirebaseUser() + if (!firebaseUser) return null + + const changeUserEmail = async (newEmail: string) => { + if (!firebaseUser) return + + try { + await updateEmail(firebaseUser, newEmail) + toast.success(t('settings.email.updated_success', 'Email updated successfully')) + setIsChangingEmail(false) + reset() + // Force a reload to update the UI with the new email + // window.location.reload() + } catch (error: any) { + console.error('Error updating email:', error) + toast.error(error.message || t('settings.email.update_failed', 'Failed to update email')) + } + } + + const onSubmitEmailChange = (data: {newEmail: string}) => { + if (!firebaseUser) return + if (data.newEmail === firebaseUser.email) { + toast.error(t('settings.email.same_as_current', 'New email is the same as current email')) + return + } + changeUserEmail(data.newEmail) + } + + const isEmailVerified = firebaseUser.emailVerified + + return ( + <> + + } + title={t('settings.section.appearance', 'Appearance & language')} + description={t( + 'settings.section.appearance_description', + 'How Compass looks and reads on this device.', + )} + style={enter(0)} + > + + + + + + + + + + + + + + + + + + } + title={t('settings.data_privacy.title', 'Data & Privacy')} + description={t( + 'settings.data_privacy.card_description', + 'Everything Compass holds about you is yours to take.', + )} + style={enter(1)} + > + + + + } + title={t('settings.general.people', 'People')} + description={t( + 'settings.people.card_description', + 'Profiles you have chosen not to see.', + )} + style={enter(2)} + > + + + + + + } + title={t('settings.connection_preferences.title', 'Connection Preferences')} + description={t( + 'settings.connection_preferences.description', + 'Control how others can connect with you.', + )} + style={enter(3)} + > + + + + } + title={t('settings.general.account', 'Account')} + description={t( + 'settings.account.card_description', + 'How you sign in and how we reach you.', + )} + style={enter(4)} + > + {/* The address itself, not just buttons that act on it. The old layout offered "Change + email address" without ever saying what the current one was. */} + + {privateUser?.email} + {isEmailVerified ? ( + + + {t('settings.email.verified_short', 'Verified')} + + ) : ( + + {t('settings.email.unverified_short', 'Not verified')} + + )} + + } + > + {!isChangingEmail ? ( + + ) : ( +
+ + + {errors.newEmail && ( + + {errors.newEmail.message === 'Email is required' + ? t('settings.email.required', 'Email is required') + : errors.newEmail.message} + + )} + +
+ + +
+
+ )} +
+ + {/* Only worth a row while there is something to do about it. */} + {!isEmailVerified && ( + + + + )} + + + + +
+ + } + tone="danger" + title={t('settings.danger_zone', 'Danger Zone')} + description={t( + 'settings.danger_zone.description', + 'Deleting your account removes your profile, messages and answers. This cannot be undone.', + )} + style={enter(5)} + > + + + + + + + {/* Hidden profiles modal */} + + + ) +} + +const DataPrivacySettings = () => { + const t = useT() + const user = useUser() + const [isDownloading, setIsDownloading] = useState(false) + + const handleDownload = async () => { + if (isDownloading) return + + try { + setIsDownloading(true) + const data = await api('me/data', {}) + const jsonString = JSON.stringify(data, null, 2) + const filename = `compass-data-export${user?.username ? `-${user.username}` : ''}.json` + if (isNativeMobile() && window.AndroidBridge && window.AndroidBridge.downloadFile) { + window.AndroidBridge.downloadFile(filename, jsonString) + } else { + const blob = new Blob([jsonString], {type: 'application/json'}) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + toast.success( + t('settings.data_privacy.download.success', 'Your data export has been downloaded.'), + ) + } catch (e) { + console.error('Error downloading data export', e) + toast.error( + t( + 'settings.data_privacy.download.error', + 'Failed to download your data. Please try again.', + ), + ) + } finally { + setIsDownloading(false) + } + } + + return ( + + + + ) +} diff --git a/web/components/settings/settings-card.tsx b/web/components/settings/settings-card.tsx new file mode 100644 index 00000000..808d3596 --- /dev/null +++ b/web/components/settings/settings-card.tsx @@ -0,0 +1,123 @@ +import clsx from 'clsx' +import {CSSProperties, ReactNode} from 'react' +import {Col} from 'web/components/layout/col' +import {surface} from 'web/components/widgets/surface' + +/** + * The settings page's two building blocks. + * + * The General tab used to be a flat run of `

` + control in a single `gap-2` column: eight + * sections at identical visual weight, so nothing could read as more or less important, and the + * container's `max-w-fit` collapsed the whole thing to ~400px against a full-width page. The + * Notifications tab, meanwhile, already grouped its rows into bordered cards — the same page + * disagreeing with itself about what a settings section looks like. + * + * These two components are that card treatment, generalised. `SettingsCard` inherits `surface` from + * `widgets/surface` — the vocabulary `/about` and `/home` already use — rather than inventing a + * third elevation. + */ + +/** + * One group of related settings: icon, title, one-line description, then a divided list of rows. + * + * `data-form-section` is what the section index reads to build itself (see `ProfileFormNav`), so the + * markup stays the source of truth for what sections exist and in what order. + */ +export function SettingsCard(props: { + id: string + title: string + description?: string + icon: ReactNode + /** `danger` tints the ring and the icon chip red. Reserved for irreversible actions. */ + tone?: 'default' | 'danger' + className?: string + style?: CSSProperties + children: ReactNode +}) { + const {id, title, description, icon, tone = 'default', className, style, children} = props + const isDanger = tone === 'danger' + + return ( +
+ {/* The icon is centred on the title line, not on the title-plus-description block: aligned to + the block it floats between the two lines, level with neither. The description then hangs + at `pl-12` (chip + gap) so it still starts on the title's left edge. */} +
+
+ svg]:h-5 [&>svg]:w-5', + isDanger + ? 'bg-red-500/10 text-red-500' + : 'bg-primary-500/10 text-primary-600 dark:text-primary-500', + )} + > + {icon} + +

{title}

+
+ {description && ( +

{description}

+ )} +
+ {children} +
+ ) +} + +/** + * One setting: what it is on the left, the control that changes it on the right. + * + * The pattern is lifted from `ConnectionPreferencesSettings`, which was already the only part of the + * General tab that explained its own options. Applying it to language/theme/font/units turns four + * orphaned headings into four rows of one card. + * + * Below `sm` the control drops under the label rather than squeezing beside it — a ` - {errors.newEmail && ( - - {errors.newEmail.message === 'Email is required' - ? t('settings.email.required', 'Email is required') - : errors.newEmail.message} - - )} - -
- - -
- - )} - -

{t('settings.general.password', 'Password')}
- - -
{t('settings.danger_zone', 'Danger Zone')}
-
- -
-
- - {/* Hidden profiles modal */} - - - ) -} - -const DataPrivacySettings = () => { - const t = useT() - const user = useUser() - const [isDownloading, setIsDownloading] = useState(false) - - const handleDownload = async () => { - if (isDownloading) return - - try { - setIsDownloading(true) - const data = await api('me/data', {}) - const jsonString = JSON.stringify(data, null, 2) - const filename = `compass-data-export${user?.username ? `-${user.username}` : ''}.json` - if (isNativeMobile() && window.AndroidBridge && window.AndroidBridge.downloadFile) { - window.AndroidBridge.downloadFile(filename, jsonString) - } else { - const blob = new Blob([jsonString], {type: 'application/json'}) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) - } - toast.success( - t('settings.data_privacy.download.success', 'Your data export has been downloaded.'), - ) - } catch (e) { - console.error('Error downloading data export', e) - toast.error( - t( - 'settings.data_privacy.download.error', - 'Failed to download your data. Please try again.', - ), - ) - } finally { - setIsDownloading(false) - } - } - - return ( -
-

- {t( - 'settings.data_privacy.description', - 'Download a JSON file containing all your information: profile, account, messages, compatibility answers, starred profiles, votes, endorsements, search bookmarks, etc.', - )} -

- -
- ) -}