| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AsyncResult } from "effect/reactivity"; |
| 3 | import { useState } from "react"; |
| 4 | import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; |
| 5 | import { Button } from "../ui/button"; |
| 6 | import { Select, SelectTrigger, SelectPopup, SelectItem, SelectValue } from "../ui/select"; |
| 7 | import { useEnvironments } from "../../state/environments"; |
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 9 | import { |
| 10 | disableUiSettingsSync, |
| 11 | enableUiSettingsSync, |
| 12 | uiSettingsSyncAtoms, |
| 13 | useUiSettingsSync, |
| 14 | } from "../../state/uiSettingsSync"; |
| 15 | import { SettingsRow, SettingsSection } from "./settingsLayout"; |
| 16 | import { searchableSetting } from "./settingsSearch"; |
| 17 | |
| 18 | export function UiSettingsSyncSection() { |
| 19 | const sync = useUiSettingsSync(); |
| 20 | const { environments } = useEnvironments(); |
| 21 | const [selection, setSelection] = useState(""); |
| 22 | const [saved, setSaved] = useState<{ |
| 23 | environmentId: EnvironmentId; |
| 24 | profile: UiSettingsProfile; |
| 25 | } | null>(null); |
| 26 | const [busy, setBusy] = useState(false); |
| 27 | const [error, setError] = useState<string | null>(null); |
| 28 | const read = useAtomCommand(uiSettingsSyncAtoms.read, { reportFailure: false }); |
| 29 | const selected = selection ? EnvironmentId.make(selection) : null; |
| 30 | const permission = useAtomValue(uiSettingsSyncAtoms.patch.permissionAtom(selected)); |
| 31 | const eligible = environments.filter( |
| 32 | (environment) => |
| 33 | environment.connection.phase === "connected" && |
| 34 | environment.serverConfig?.environment.capabilities.uiSettingsSync === true, |
| 35 | ); |
| 36 | const active = environments.find( |
| 37 | (environment) => environment.environmentId === sync.environmentId, |
| 38 | ); |
| 39 | |
| 40 | async function inspect() { |
| 41 | if (!selected) return; |
| 42 | setBusy(true); |
| 43 | setError(null); |
| 44 | const result = await read({ environmentId: selected, input: {} }); |
| 45 | if (AsyncResult.isSuccess(result)) setSaved({ environmentId: selected, profile: result.value }); |
| 46 | else setError("Unable to read the profile. Reconnect to this environment and try again."); |
| 47 | setBusy(false); |
| 48 | } |
| 49 | |
| 50 | async function enable(mode: "adopt" | "publish") { |
| 51 | if (!saved) return; |
| 52 | setBusy(true); |
| 53 | setError(null); |
| 54 | try { |
| 55 | await enableUiSettingsSync(saved.environmentId, saved.profile, mode); |
| 56 | setSaved(null); |
| 57 | setSelection(""); |
| 58 | } catch { |
| 59 | setError("Unable to enable sync. Check local storage access and try again."); |
| 60 | } finally { |
| 61 | setBusy(false); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | return ( |
| 66 | <SettingsSection id="ui-settings-sync-section" title="UI settings sync"> |
| 67 | <SettingsRow |
| 68 | {...searchableSetting("ui-settings-sync")} |
| 69 | description="Sync appearance, fonts, and chat preferences through one private environment. Enable it on each device." |
| 70 | control={ |
| 71 | sync.environmentId !== null || sync.status === "error" ? ( |
| 72 | <Button |
| 73 | size="sm" |
| 74 | variant="outline" |
| 75 | disabled={busy} |
| 76 | onClick={() => { |
| 77 | void disableUiSettingsSync().catch(() => |
| 78 | setError("Unable to disable sync. Check local storage access and try again."), |
| 79 | ); |
| 80 | }} |
| 81 | > |
| 82 | Disable sync |
| 83 | </Button> |
| 84 | ) : undefined |
| 85 | } |
| 86 | > |
| 87 | <div className="space-y-3"> |
| 88 | <p role="status" className="text-sm text-muted-foreground"> |
| 89 | {sync.environmentId === null |
| 90 | ? "Off" |
| 91 | : sync.status === "synced" |
| 92 | ? `Following ${active?.label ?? "your saved environment"}` |
| 93 | : sync.status === "syncing" |
| 94 | ? "Syncing…" |
| 95 | : sync.status === "error" |
| 96 | ? "Sync paused. Reconnect to the profile environment to try again." |
| 97 | : "Offline. Changes stay on this device until the profile environment reconnects."} |
| 98 | </p> |
| 99 | {sync.conflicts.length > 0 ? ( |
| 100 | <p role="status" className="text-sm text-muted-foreground"> |
| 101 | Newer saved preferences replaced conflicting edits. Change a preference again to use |
| 102 | your choice. |
| 103 | </p> |
| 104 | ) : null} |
| 105 | <div className="flex flex-wrap items-center gap-2"> |
| 106 | <Select |
| 107 | value={selection} |
| 108 | disabled={busy} |
| 109 | onValueChange={(value) => { |
| 110 | setSelection(value ?? ""); |
| 111 | setSaved(null); |
| 112 | setError(null); |
| 113 | }} |
| 114 | > |
| 115 | <SelectTrigger size="sm" aria-label="Profile environment"> |
| 116 | <SelectValue> |
| 117 | {eligible.find((entry) => entry.environmentId === selection)?.label ?? |
| 118 | "Choose private environment"} |
| 119 | </SelectValue> |
| 120 | </SelectTrigger> |
| 121 | <SelectPopup> |
| 122 | {eligible.map((environment) => ( |
| 123 | <SelectItem key={environment.environmentId} value={environment.environmentId}> |
| 124 | {environment.label} |
| 125 | </SelectItem> |
| 126 | ))} |
| 127 | </SelectPopup> |
| 128 | </Select> |
| 129 | <Button |
| 130 | size="sm" |
| 131 | variant="outline" |
| 132 | disabled={!selected || !permission || busy} |
| 133 | onClick={() => void inspect()} |
| 134 | > |
| 135 | Read profile |
| 136 | </Button> |
| 137 | </div> |
| 138 | {eligible.length === 0 ? ( |
| 139 | <p className="text-sm text-muted-foreground"> |
| 140 | Connect to an updated private environment to set up sync. |
| 141 | </p> |
| 142 | ) : null} |
| 143 | {selected && !permission ? ( |
| 144 | <p className="text-sm text-muted-foreground"> |
| 145 | Settings access is required. Update this connection’s permissions in Connections. |
| 146 | </p> |
| 147 | ) : null} |
| 148 | {saved ? ( |
| 149 | <div className="space-y-2"> |
| 150 | <p className="text-sm text-muted-foreground"> |
| 151 | {saved.profile.revision === 0 |
| 152 | ? "No profile saved. Publish this device’s current preferences to start." |
| 153 | : "Use the saved profile, or publish this device’s current preferences. Other participating devices will follow it."} |
| 154 | </p> |
| 155 | <div className="flex flex-wrap gap-2"> |
| 156 | {saved.profile.revision > 0 ? ( |
| 157 | <Button size="sm" disabled={busy} onClick={() => void enable("adopt")}> |
| 158 | Use saved |
| 159 | </Button> |
| 160 | ) : null} |
| 161 | <Button |
| 162 | size="sm" |
| 163 | variant={saved.profile.revision > 0 ? "outline" : "default"} |
| 164 | disabled={busy || !permission} |
| 165 | onClick={() => void enable("publish")} |
| 166 | > |
| 167 | Publish current |
| 168 | </Button> |
| 169 | <Button size="sm" variant="ghost" disabled={busy} onClick={() => setSaved(null)}> |
| 170 | Cancel |
| 171 | </Button> |
| 172 | </div> |
| 173 | </div> |
| 174 | ) : null} |
| 175 | {error ? ( |
| 176 | <p role="alert" className="text-sm text-destructive"> |
| 177 | {error} |
| 178 | </p> |
| 179 | ) : null} |
| 180 | </div> |
| 181 | </SettingsRow> |
| 182 | </SettingsSection> |
| 183 | ); |
| 184 | } |