mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 (
|
||||
<Col className="gap-4">
|
||||
<div className="text-sm text-ink-500">
|
||||
{t(
|
||||
'settings.connection_preferences.description',
|
||||
'Control how others can connect with you.',
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t('settings.connection_preferences.direct_messaging', 'Direct Messaging')}
|
||||
description={t(
|
||||
'settings.connection_preferences.dm_description',
|
||||
'Let anyone start a conversation with you immediately.',
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<Row className="items-center gap-3">
|
||||
<SavedFlash field="allow_direct_messaging" saved={saved} />
|
||||
<SwitchSetting
|
||||
testId="settings-direct-message-toggle"
|
||||
checked={allowDirectMessaging}
|
||||
onChange={handleDirectMessagingChange}
|
||||
disabled={isUpdating}
|
||||
colorMode={'primary'}
|
||||
/>
|
||||
</Row>
|
||||
</SettingsRow>
|
||||
|
||||
<Row className="items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{t('settings.connection_preferences.direct_messaging', 'Direct Messaging')}
|
||||
</div>
|
||||
<div className="text-ink-500 text-sm">
|
||||
{t(
|
||||
'settings.connection_preferences.dm_description',
|
||||
'Let anyone start a conversation with you immediately.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SwitchSetting
|
||||
testId="settings-direct-message-toggle"
|
||||
checked={allowDirectMessaging}
|
||||
onChange={handleDirectMessagingChange}
|
||||
disabled={isUpdating}
|
||||
colorMode={'primary'}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row className="items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{t('settings.connection_preferences.interest_indicator', 'Private interest signals')}
|
||||
</div>
|
||||
<div className="text-ink-500 text-sm">
|
||||
{t(
|
||||
'settings.connection_preferences.indicator_description',
|
||||
'Allow people to privately signal interest. You are only notified if the interest is mutual.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SwitchSetting
|
||||
testId="settings-private-interest-signal-toggle"
|
||||
checked={allowInterestIndicating}
|
||||
onChange={handleInterestIndicatingChange}
|
||||
disabled={isUpdating}
|
||||
colorMode={'primary'}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
<SettingsRow
|
||||
label={t('settings.connection_preferences.interest_indicator', 'Private interest signals')}
|
||||
description={t(
|
||||
'settings.connection_preferences.indicator_description',
|
||||
'Allow people to privately signal interest. You are only notified if the interest is mutual.',
|
||||
)}
|
||||
>
|
||||
<Row className="items-center gap-3">
|
||||
<SavedFlash field="allow_interest_indicating" saved={saved} />
|
||||
<SwitchSetting
|
||||
testId="settings-private-interest-signal-toggle"
|
||||
checked={allowInterestIndicating}
|
||||
onChange={handleInterestIndicatingChange}
|
||||
disabled={isUpdating}
|
||||
colorMode={'primary'}
|
||||
/>
|
||||
</Row>
|
||||
</SettingsRow>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span
|
||||
aria-hidden={!visible}
|
||||
className={clsx(
|
||||
'text-ink-500 flex items-center gap-1 text-xs transition-opacity duration-300',
|
||||
visible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
{t('settings.saved', 'Saved')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function FontPicker(props: {className?: string} = {}) {
|
||||
value={font}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => 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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function LanguagePicker(props: {className?: string} = {}) {
|
||||
value={locale}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => 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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<ControlledTabs
|
||||
{...rest}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {useCallback} from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {SwitchSetting} from 'web/components/switch-setting'
|
||||
import {WithPrivateUser} from 'web/components/user/with-user'
|
||||
import {surface} from 'web/components/widgets/surface'
|
||||
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useT} from 'web/lib/locale'
|
||||
@@ -104,31 +105,33 @@ function LoadedNotificationSettings(props: {privateUser: PrivateUser}) {
|
||||
|
||||
return (
|
||||
<Col className="mt-4 w-full gap-6">
|
||||
<Col className="border-canvas-300 bg-canvas-50 overflow-hidden rounded-xl border">
|
||||
<div className="border-canvas-300 border-b px-4 py-3">
|
||||
<Col className={clsx(surface, 'overflow-hidden')}>
|
||||
<div className="border-canvas-200/70 border-b px-4 py-3">
|
||||
<p className="text-ink-900 font-medium">
|
||||
{t('notifications.heading', 'Where do you want to be notified when someone')}
|
||||
</p>
|
||||
</div>
|
||||
<DestinationHeader />
|
||||
<Col className="divide-canvas-300 divide-y">
|
||||
<Col className="divide-canvas-200/70 divide-y">
|
||||
{socialNotificationTypes.map(renderOption)}
|
||||
</Col>
|
||||
</Col>
|
||||
|
||||
<Col className="border-canvas-300 bg-canvas-50 overflow-hidden rounded-xl border">
|
||||
<div className="border-canvas-300 border-b px-4 py-3">
|
||||
<Col className={clsx(surface, 'overflow-hidden')}>
|
||||
<div className="border-canvas-200/70 border-b px-4 py-3">
|
||||
<p className="text-ink-900 font-medium">
|
||||
{t('notifications.section.other', 'Other updates')}
|
||||
</p>
|
||||
</div>
|
||||
<DestinationHeader />
|
||||
<Col className="divide-canvas-300 divide-y">{otherNotificationTypes.map(renderOption)}</Col>
|
||||
<Col className="divide-canvas-200/70 divide-y">
|
||||
{otherNotificationTypes.map(renderOption)}
|
||||
</Col>
|
||||
</Col>
|
||||
|
||||
{/* 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. */}
|
||||
<Col className="border-canvas-300 bg-canvas-50 overflow-hidden rounded-xl border">
|
||||
<Col className={clsx(surface, 'overflow-hidden')}>
|
||||
{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 (
|
||||
<div className={clsx(OPTION_GRID, 'border-canvas-300 border-b px-4 py-2')}>
|
||||
<div className={clsx(OPTION_GRID, 'border-canvas-200/70 border-b px-4 py-2')}>
|
||||
<span />
|
||||
{SHOWN_NOTIFICATION_DESTINATION_TYPES.map((destinationType) => (
|
||||
<span
|
||||
|
||||
@@ -131,9 +131,12 @@ export function DeleteAccountSurveyModal() {
|
||||
return (
|
||||
<ConfirmationButton
|
||||
openModalBtn={{
|
||||
className: 'p-2',
|
||||
className: 'p-2 w-full sm:w-fit',
|
||||
label: t('delete_yourself.open_label', 'Delete account'),
|
||||
color: 'red',
|
||||
// Outline, not solid. On the settings page this was the only coloured button among a dozen
|
||||
// grey ones, which made the irreversible action the most prominent thing on the page. The
|
||||
// modal's own confirm button is still solid red — the emphasis belongs where the decision is.
|
||||
color: 'red-outline',
|
||||
}}
|
||||
cancelBtn={
|
||||
showStayPitch
|
||||
|
||||
404
web/components/settings/general-settings.tsx
Normal file
404
web/components/settings/general-settings.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeSlashIcon,
|
||||
PaintBrushIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import {CheckBadgeIcon} from '@heroicons/react/24/solid'
|
||||
import {PrivateUser} from 'common/src/user'
|
||||
import {updateEmail} from 'firebase/auth'
|
||||
import {useState} from 'react'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {ConnectionPreferencesSettings} from 'web/components/connection-preferences-settings'
|
||||
import {EmailVerificationButton} from 'web/components/email-verification-button'
|
||||
import {FontPicker} from 'web/components/font-picker'
|
||||
import {LanguagePicker} from 'web/components/language/language-picker'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import MeasurementSystemToggle from 'web/components/measurement-system-toggle'
|
||||
import {DeleteAccountSurveyModal} from 'web/components/profile/delete-account-survey-modal'
|
||||
import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal'
|
||||
import {SettingsCard, SettingsRow} from 'web/components/settings/settings-card'
|
||||
import {ThemePicker} from 'web/components/settings/theme-picker'
|
||||
import {WithPrivateUser} from 'web/components/user/with-user'
|
||||
import {Input} from 'web/components/widgets/input'
|
||||
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
import {sendPasswordReset} from 'web/lib/firebase/password'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {isNativeMobile} from 'web/lib/util/webview'
|
||||
|
||||
export const GeneralSettings = () => (
|
||||
<WithPrivateUser>{(user) => <LoadedGeneralSettings privateUser={user} />}</WithPrivateUser>
|
||||
)
|
||||
|
||||
/** 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 (
|
||||
<>
|
||||
<Col className="mt-4 w-full gap-6" data-settings-sections>
|
||||
<SettingsCard
|
||||
id="settings-appearance"
|
||||
icon={<PaintBrushIcon />}
|
||||
title={t('settings.section.appearance', 'Appearance & language')}
|
||||
description={t(
|
||||
'settings.section.appearance_description',
|
||||
'How Compass looks and reads on this device.',
|
||||
)}
|
||||
style={enter(0)}
|
||||
>
|
||||
<SettingsRow
|
||||
htmlFor="locale-picker"
|
||||
label={t('settings.general.language', 'Language')}
|
||||
description={t(
|
||||
'settings.general.language_description',
|
||||
'The language of the interface.',
|
||||
)}
|
||||
>
|
||||
<LanguagePicker className={'w-full min-w-[160px] !bg-canvas-0 sm:w-fit'} />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t('settings.general.theme', 'Theme')}
|
||||
description={t(
|
||||
'settings.general.theme_description',
|
||||
'Auto follows your device’s light or dark setting.',
|
||||
)}
|
||||
>
|
||||
<ThemePicker />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
htmlFor="font-picker"
|
||||
label={t('settings.general.font', 'Font')}
|
||||
description={t(
|
||||
'settings.general.font_description',
|
||||
'Atkinson Hyperlegible is designed for easier reading.',
|
||||
)}
|
||||
>
|
||||
<FontPicker className={'w-full min-w-[200px] !bg-canvas-0 sm:w-fit'} />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t('settings.general.measurement', 'Measurement System')}
|
||||
description={t(
|
||||
'settings.general.measurement_description',
|
||||
'Used for heights and distances.',
|
||||
)}
|
||||
>
|
||||
<MeasurementSystemToggle />
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
id="settings-privacy"
|
||||
icon={<ShieldCheckIcon />}
|
||||
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)}
|
||||
>
|
||||
<DataPrivacySettings />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
id="settings-people"
|
||||
icon={<EyeSlashIcon />}
|
||||
title={t('settings.general.people', 'People')}
|
||||
description={t(
|
||||
'settings.people.card_description',
|
||||
'Profiles you have chosen not to see.',
|
||||
)}
|
||||
style={enter(2)}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t('settings.hidden_profiles.title', 'Hidden profiles')}
|
||||
description={t(
|
||||
'settings.hidden_profiles.description',
|
||||
'Hidden profiles stay out of your browsing and search results.',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
color={'gray-outline'}
|
||||
onClick={() => setShowHiddenProfiles(true)}
|
||||
className="w-full sm:w-fit"
|
||||
>
|
||||
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
id="settings-connections"
|
||||
icon={<SparklesIcon />}
|
||||
title={t('settings.connection_preferences.title', 'Connection Preferences')}
|
||||
description={t(
|
||||
'settings.connection_preferences.description',
|
||||
'Control how others can connect with you.',
|
||||
)}
|
||||
style={enter(3)}
|
||||
>
|
||||
<ConnectionPreferencesSettings />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
id="settings-account"
|
||||
icon={<UserCircleIcon />}
|
||||
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. */}
|
||||
<SettingsRow
|
||||
stacked={isChangingEmail}
|
||||
label={t('settings.general.email', 'Email')}
|
||||
description={
|
||||
<Row className="flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-ink-700 break-all">{privateUser?.email}</span>
|
||||
{isEmailVerified ? (
|
||||
<span className="text-ink-500 inline-flex items-center gap-1 text-xs">
|
||||
<CheckBadgeIcon className="h-4 w-4 text-teal-500" aria-hidden />
|
||||
{t('settings.email.verified_short', 'Verified')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs">
|
||||
{t('settings.email.unverified_short', 'Not verified')}
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
}
|
||||
>
|
||||
{!isChangingEmail ? (
|
||||
<Button
|
||||
color={'gray-outline'}
|
||||
onClick={() => setIsChangingEmail(true)}
|
||||
className="w-full sm:w-fit"
|
||||
>
|
||||
{t('settings.email.change', 'Change email address')}
|
||||
</Button>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmitEmailChange)}
|
||||
className="flex w-full flex-col gap-2 sm:max-w-sm"
|
||||
>
|
||||
<Col>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('settings.email.new_placeholder', 'New email address')}
|
||||
{...register('newEmail', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: t('settings.email.invalid', 'Invalid email address'),
|
||||
},
|
||||
})}
|
||||
disabled={!firebaseUser}
|
||||
/>
|
||||
{errors.newEmail && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.newEmail.message === 'Email is required'
|
||||
? t('settings.email.required', 'Email is required')
|
||||
: errors.newEmail.message}
|
||||
</span>
|
||||
)}
|
||||
</Col>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" color="green" className="w-fit">
|
||||
{t('settings.action.save', 'Save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setIsChangingEmail(false)
|
||||
reset()
|
||||
}}
|
||||
className="w-fit"
|
||||
>
|
||||
{t('settings.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{/* Only worth a row while there is something to do about it. */}
|
||||
{!isEmailVerified && (
|
||||
<SettingsRow
|
||||
label={t('settings.email.verify', 'Verify your email')}
|
||||
description={t(
|
||||
'settings.email.verify_description',
|
||||
'Some features stay locked until your address is confirmed.',
|
||||
)}
|
||||
>
|
||||
<EmailVerificationButton />
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<SettingsRow
|
||||
label={t('settings.general.password', 'Password')}
|
||||
description={t(
|
||||
'settings.password.description',
|
||||
'We’ll email you a link to choose a new one.',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
onClick={() => sendPasswordReset(privateUser?.email)}
|
||||
className="w-full sm:w-fit"
|
||||
color={'gray-outline'}
|
||||
>
|
||||
{t('settings.password.send_reset', 'Send password reset email')}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
id="settings-danger"
|
||||
icon={<ExclamationTriangleIcon />}
|
||||
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)}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t('settings.delete_account', 'Delete account')}
|
||||
description={t(
|
||||
'settings.delete_account.description',
|
||||
'You’ll be asked why before anything is removed.',
|
||||
)}
|
||||
>
|
||||
<DeleteAccountSurveyModal />
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</Col>
|
||||
|
||||
{/* Hidden profiles modal */}
|
||||
<HiddenProfilesModal open={showHiddenProfiles} setOpen={setShowHiddenProfiles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<SettingsRow
|
||||
label={t('settings.data_privacy.export', 'Export your data')}
|
||||
description={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.',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
color="gray-outline"
|
||||
onClick={handleDownload}
|
||||
className="w-full sm:w-fit"
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
>
|
||||
<ArrowDownTrayIcon className="mr-2 h-4 w-4" aria-hidden />
|
||||
{isDownloading
|
||||
? t('settings.data_privacy.downloading', 'Downloading...')
|
||||
: t('settings.data_privacy.download', 'Download all my data')}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
123
web/components/settings/settings-card.tsx
Normal file
123
web/components/settings/settings-card.tsx
Normal file
@@ -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 `<h3>` + 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 (
|
||||
<section
|
||||
id={id}
|
||||
data-form-section={title}
|
||||
style={style}
|
||||
className={clsx(
|
||||
surface,
|
||||
'animate-fade-up overflow-hidden',
|
||||
// `red`, not `scarlet` — the latter is this theme's grey "no" token, not a red.
|
||||
isDanger && '!ring-red-500/30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 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. */}
|
||||
<header className="border-canvas-200/70 border-b px-4 py-4 sm:px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
'flex h-9 w-9 flex-none items-center justify-center rounded-xl [&>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}
|
||||
</span>
|
||||
<h2 className="text-ink-1000 min-w-0 text-base font-semibold leading-tight">{title}</h2>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-ink-500 mt-2 pl-12 text-sm leading-snug">{description}</p>
|
||||
)}
|
||||
</header>
|
||||
<Col className="divide-canvas-200/70 divide-y">{children}</Col>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<select>` and a
|
||||
* three-way pill group have no useful narrow form.
|
||||
*/
|
||||
export function SettingsRow(props: {
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
/** Renders the label as a `<label for=...>` when the control is a real form element. */
|
||||
htmlFor?: string
|
||||
/** Stacks the control under the label at every width. For controls that need the room. */
|
||||
stacked?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const {label, description, htmlFor, stacked, className, children} = props
|
||||
const Label = htmlFor ? 'label' : 'div'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-3 px-4 py-4 sm:px-5',
|
||||
!stacked && 'sm:flex-row sm:items-center sm:justify-between sm:gap-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className={clsx('text-ink-1000 block text-sm font-medium', htmlFor && 'cursor-pointer')}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{description && <p className="text-ink-500 mt-1 text-sm leading-snug">{description}</p>}
|
||||
</div>
|
||||
<div className={clsx('min-w-0', !stacked && 'sm:flex-none')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
web/components/settings/theme-picker.tsx
Normal file
54
web/components/settings/theme-picker.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import {ComputerDesktopIcon, MoonIcon, SunIcon} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {pillBase, pillColors} from 'web/components/widgets/choices-toggle-group'
|
||||
import {useTheme} from 'web/hooks/use-theme'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
type ThemeOption = 'light' | 'dark' | 'auto'
|
||||
|
||||
/**
|
||||
* Light / Dark / Auto as three pills.
|
||||
*
|
||||
* The previous control was a single button that cycled and labelled itself with the *current* state
|
||||
* ("Dark (auto)"). A control that only ever shows where you are hides both that a third option
|
||||
* exists and how many clicks away it is — you had to press it and watch to find out. Three pills say
|
||||
* the whole state space at a glance and cost one click to reach any of it.
|
||||
*
|
||||
* Built on `pillBase`/`pillColors` rather than `ChoicesToggleGroup` because each option carries an
|
||||
* icon and its own test id; the pill spec itself is shared so the two read as one control type.
|
||||
*/
|
||||
export function ThemePicker(props: {className?: string}) {
|
||||
const {className} = props
|
||||
const {theme, setTheme} = useTheme()
|
||||
const t = useT()
|
||||
|
||||
const options: {value: ThemeOption; label: string; icon: typeof SunIcon}[] = [
|
||||
{value: 'light', label: t('theme.light', 'Light'), icon: SunIcon},
|
||||
{value: 'dark', label: t('theme.dark', 'Dark'), icon: MoonIcon},
|
||||
{value: 'auto', label: t('theme.auto', 'Auto'), icon: ComputerDesktopIcon},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.general.theme', 'Theme')}
|
||||
data-testid="settings-dark-light-toggle"
|
||||
className={clsx('flex flex-row flex-wrap gap-2', className)}
|
||||
>
|
||||
{options.map(({value, label, icon: Icon}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === value}
|
||||
data-testid={`settings-theme-${value}`}
|
||||
onClick={() => setTheme(value)}
|
||||
className={clsx(pillBase, pillColors['indigo-dark'], 'gap-1.5')}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,17 @@ const SELECTED_CTA =
|
||||
const UNSELECTED =
|
||||
'border-canvas-300 bg-canvas-0 text-ink-600 hover:border-primary-400 hover:text-primary-700'
|
||||
|
||||
/**
|
||||
* The pill's shape, motion and focus ring, split out so other single-choice controls that are not
|
||||
* built on `RadioGroup` — the theme picker in settings — can be the same object rather than a
|
||||
* lookalike. Pair it with `pillColors[...]`, which keys off `aria-checked`, so any control using
|
||||
* these must set that attribute.
|
||||
*/
|
||||
export const pillBase = clsx(
|
||||
'inline-flex cursor-pointer select-none items-center rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-all duration-150',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1 focus-visible:ring-offset-canvas-50',
|
||||
)
|
||||
|
||||
const colorClasses = {
|
||||
'indigo-dark': `${UNSELECTED} ${SELECTED_CTA}`,
|
||||
indigo: `${UNSELECTED} aria-checked:border-primary-400 aria-checked:bg-primary-100 aria-checked:text-primary-900`,
|
||||
@@ -17,6 +28,8 @@ const colorClasses = {
|
||||
red: `${UNSELECTED} aria-checked:border-scarlet-500 aria-checked:bg-scarlet-500 aria-checked:text-ink-0`,
|
||||
}
|
||||
|
||||
export const pillColors = colorClasses
|
||||
|
||||
export type ColorType = keyof typeof colorClasses
|
||||
|
||||
export function ChoicesToggleGroup<T extends Record<string, string | number | boolean>>(props: {
|
||||
@@ -84,8 +97,7 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
|
||||
}
|
||||
className={({disabled}) =>
|
||||
clsx(
|
||||
'inline-flex cursor-pointer select-none items-center rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-all duration-150',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1 focus-visible:ring-offset-canvas-50',
|
||||
pillBase,
|
||||
disabled
|
||||
? 'border-canvas-200 bg-canvas-100 text-ink-300 cursor-not-allowed'
|
||||
: colorClasses[color],
|
||||
|
||||
@@ -1,272 +1,65 @@
|
||||
import {PrivateUser} from 'common/src/user'
|
||||
import {updateEmail} from 'firebase/auth'
|
||||
import {useState} from 'react'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import {AboutSettings} from 'web/components/about-settings'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {ConnectionPreferencesSettings} from 'web/components/connection-preferences-settings'
|
||||
import {EmailVerificationButton} from 'web/components/email-verification-button'
|
||||
import {FontPicker} from 'web/components/font-picker'
|
||||
import {LanguagePicker} from 'web/components/language/language-picker'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {UncontrolledTabs} from 'web/components/layout/tabs'
|
||||
import MeasurementSystemToggle from 'web/components/measurement-system-toggle'
|
||||
import {NoSEO} from 'web/components/NoSEO'
|
||||
import {NotificationSettings} from 'web/components/notifications'
|
||||
import {PageBase} from 'web/components/page-base'
|
||||
import {DeleteAccountSurveyModal} from 'web/components/profile/delete-account-survey-modal'
|
||||
import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal'
|
||||
import ThemeIcon from 'web/components/theme-icon'
|
||||
import {WithPrivateUser} from 'web/components/user/with-user'
|
||||
import {Input} from 'web/components/widgets/input'
|
||||
import {ProfileFormNav} from 'web/components/profile-form-nav'
|
||||
import {GeneralSettings} from 'web/components/settings/general-settings'
|
||||
import {Title} from 'web/components/widgets/title'
|
||||
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
|
||||
import {useRedirectIfSignedOut} from 'web/hooks/use-redirect-if-signed-out'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
import {sendPasswordReset} from 'web/lib/firebase/password'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {isNativeMobile} from 'web/lib/util/webview'
|
||||
|
||||
/** The container the section index reads. See `SettingsCard`. */
|
||||
export const SETTINGS_SECTIONS_ROOT = '[data-settings-sections]'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const t = useT()
|
||||
useRedirectIfSignedOut()
|
||||
// Inactive tabs stay mounted (merely `hidden`), so the General tab's sections remain queryable
|
||||
// from every tab. Without this the index would sit beside Notifications listing sections that are
|
||||
// not on screen, and scroll to nothing when clicked.
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
// `col-span-10` rather than the default 8: at 8 the page reserved two grid columns of empty space
|
||||
// on the right and then squeezed the section index out of the content column's width.
|
||||
return (
|
||||
<PageBase trackPageView={'settings page'} className={'mx-4 mb-4'}>
|
||||
<PageBase trackPageView={'settings page'} className={'col-span-10 mb-8'}>
|
||||
<NoSEO title={t('settings.title', 'Settings')} />
|
||||
<Title>{t('settings.title', 'Settings')}</Title>
|
||||
<UncontrolledTabs
|
||||
name={'settings-page'}
|
||||
tabs={[
|
||||
{
|
||||
title: t('settings.tabs.general', 'General'),
|
||||
content: <GeneralSettings />,
|
||||
},
|
||||
{
|
||||
title: t('settings.tabs.notifications', 'Notifications'),
|
||||
content: <NotificationSettings />,
|
||||
},
|
||||
{
|
||||
title: t('settings.tabs.about', 'About'),
|
||||
content: <AboutSettings />,
|
||||
},
|
||||
]}
|
||||
trackingName={'settings page'}
|
||||
/>
|
||||
{/* Same shell as the profile form: a content column capped at a readable width with the
|
||||
section index in the gutter from `xl` up, rather than the old `max-w-fit` column that
|
||||
collapsed to ~400px and left the rest of the page empty. */}
|
||||
<div className="mx-auto flex w-full max-w-5xl gap-10 px-4 py-2 sm:px-6">
|
||||
<Col className="w-full min-w-0 max-w-2xl">
|
||||
<Title>{t('settings.title', 'Settings')}</Title>
|
||||
<UncontrolledTabs
|
||||
name={'settings-page'}
|
||||
tabs={[
|
||||
{
|
||||
title: t('settings.tabs.general', 'General'),
|
||||
content: <GeneralSettings />,
|
||||
},
|
||||
{
|
||||
title: t('settings.tabs.notifications', 'Notifications'),
|
||||
content: <NotificationSettings />,
|
||||
},
|
||||
{
|
||||
title: t('settings.tabs.about', 'About'),
|
||||
content: <AboutSettings />,
|
||||
},
|
||||
]}
|
||||
trackingName={'settings page'}
|
||||
onActiveIndexChange={setActiveIndex}
|
||||
/>
|
||||
</Col>
|
||||
{/* `mt-36` drops the index level with the top of the first card, past the title and tab row. */}
|
||||
{activeIndex === 0 && (
|
||||
<ProfileFormNav
|
||||
root={SETTINGS_SECTIONS_ROOT}
|
||||
className="sticky top-8 mt-36 hidden h-fit w-48 shrink-0 xl:flex"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageBase>
|
||||
)
|
||||
}
|
||||
|
||||
export const GeneralSettings = () => (
|
||||
<WithPrivateUser>{(user) => <LoadedGeneralSettings privateUser={user} />}</WithPrivateUser>
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 max-w-fit">
|
||||
<h3>{t('settings.general.language', 'Language')}</h3>
|
||||
<LanguagePicker className={'w-fit min-w-[120px]'} />
|
||||
|
||||
<h3>{t('settings.general.measurement', 'Measurement System')}</h3>
|
||||
<MeasurementSystemToggle />
|
||||
|
||||
<h3>{t('settings.general.theme', 'Theme')}</h3>
|
||||
<ThemeIcon className="h-6 w-6" />
|
||||
|
||||
<h3>{t('settings.general.font', 'Font')}</h3>
|
||||
<FontPicker className={'w-fit min-w-[180px]'} />
|
||||
|
||||
<h3>{t('settings.data_privacy.title', 'Data & Privacy')}</h3>
|
||||
<DataPrivacySettings />
|
||||
|
||||
<h3>{t('settings.general.people', 'People')}</h3>
|
||||
{/*<h5>{t('settings.hidden_profiles.title', 'Hidden profiles')}</h5>*/}
|
||||
<Button
|
||||
color={'gray-outline'}
|
||||
onClick={() => setShowHiddenProfiles(true)}
|
||||
className="w-fit"
|
||||
>
|
||||
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
|
||||
</Button>
|
||||
|
||||
<h3>{t('settings.connection_preferences.title', 'Connection Preferences')}</h3>
|
||||
<ConnectionPreferencesSettings />
|
||||
|
||||
<h3>{t('settings.general.account', 'Account')}</h3>
|
||||
<h5>{t('settings.general.email', 'Email')}</h5>
|
||||
|
||||
<EmailVerificationButton />
|
||||
|
||||
{!isChangingEmail ? (
|
||||
<Button color={'gray-outline'} onClick={() => setIsChangingEmail(true)} className="w-fit">
|
||||
{t('settings.email.change', 'Change email address')}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmitEmailChange)} className="flex flex-col gap-2">
|
||||
<Col>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('settings.email.new_placeholder', 'New email address')}
|
||||
{...register('newEmail', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: t('settings.email.invalid', 'Invalid email address'),
|
||||
},
|
||||
})}
|
||||
disabled={!firebaseUser}
|
||||
/>
|
||||
{errors.newEmail && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.newEmail.message === 'Email is required'
|
||||
? t('settings.email.required', 'Email is required')
|
||||
: errors.newEmail.message}
|
||||
</span>
|
||||
)}
|
||||
</Col>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" color="green" className="w-fit">
|
||||
{t('settings.action.save', 'Save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setIsChangingEmail(false)
|
||||
reset()
|
||||
}}
|
||||
className="w-fit"
|
||||
>
|
||||
{t('settings.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<h5>{t('settings.general.password', 'Password')}</h5>
|
||||
<Button
|
||||
onClick={() => sendPasswordReset(privateUser?.email)}
|
||||
className="mb-2 max-w-[250px] w-fit"
|
||||
color={'gray-outline'}
|
||||
>
|
||||
{t('settings.password.send_reset', 'Send password reset email')}
|
||||
</Button>
|
||||
|
||||
<h5>{t('settings.danger_zone', 'Danger Zone')}</h5>
|
||||
<div className={'w-fit'}>
|
||||
<DeleteAccountSurveyModal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden profiles modal */}
|
||||
<HiddenProfilesModal open={showHiddenProfiles} setOpen={setShowHiddenProfiles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4 max-w-xl">
|
||||
<p className="text-sm guidance">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
color="gray-outline"
|
||||
onClick={handleDownload}
|
||||
className="w-fit"
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
>
|
||||
{isDownloading
|
||||
? t('settings.data_privacy.downloading', 'Downloading...')
|
||||
: t('settings.data_privacy.download', 'Download all my data')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user