this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Port settings page with AppView URL override

+188 -22
+132 -22
apps/web/src/routes/cabinet/settings.lazy.tsx
··· 1 + import { useCallback, useEffect, useState } from "react"; 1 2 import { createLazyFileRoute } from "@tanstack/react-router"; 2 - import { useAuthStore } from "@/stores/auth"; 3 + import { UserIcon, FloppyDiskIcon, GearIcon } from "@phosphor-icons/react"; 4 + import type { AccountConfig } from "@opake/sdk"; 5 + import { PanelShell } from "@/components/cabinet/PanelShell"; 6 + import { getOpake, useAuthStore } from "@/stores/auth"; 7 + import { truncateDid } from "@/lib/format"; 8 + import { toastSuccess, toastError } from "@/stores/toast"; 3 9 4 - function Settings() { 10 + function SettingsPage() { 5 11 const session = useAuthStore((s) => s.session); 6 12 const did = session.status === "active" ? session.did : null; 7 13 const handle = session.status === "active" ? session.handle : null; 8 14 const pdsUrl = session.status === "active" ? session.pdsUrl : null; 9 15 16 + const [config, setConfig] = useState<AccountConfig | null>(null); 17 + const [appviewUrl, setAppviewUrl] = useState(""); 18 + const [savedAppviewUrl, setSavedAppviewUrl] = useState(""); 19 + const [saving, setSaving] = useState(false); 20 + 21 + // Load the persisted config once per session. The cancelled flag 22 + // silences setState-after-unmount under React StrictMode double-fire. 23 + useEffect(() => { 24 + if (!did) return; 25 + 26 + const cancelled = { current: false }; 27 + 28 + void (async () => { 29 + try { 30 + const result = await getOpake().getAccountConfig(); 31 + if (cancelled.current) return; 32 + const url = result?.appviewUrl ?? ""; 33 + setConfig(result); 34 + setAppviewUrl(url); 35 + setSavedAppviewUrl(url); 36 + } catch (err) { 37 + if (cancelled.current) return; 38 + console.error("[settings] failed to load account config:", err); 39 + } 40 + })(); 41 + 42 + return () => { 43 + cancelled.current = true; 44 + }; 45 + }, [did]); 46 + 47 + const handleAppviewSave = useCallback(() => { 48 + if (!did) return; 49 + const trimmed = appviewUrl.trim(); 50 + setSaving(true); 51 + void (async () => { 52 + try { 53 + const updated = await getOpake().updateAccountConfig({ 54 + appviewUrl: trimmed.length > 0 ? trimmed : undefined, 55 + }); 56 + setConfig(updated); 57 + setSavedAppviewUrl(updated.appviewUrl ?? ""); 58 + toastSuccess("AppView URL saved"); 59 + } catch (err) { 60 + toastError(err instanceof Error ? err.message : "Failed to save"); 61 + } finally { 62 + setSaving(false); 63 + } 64 + })(); 65 + }, [appviewUrl, did]); 66 + 67 + const appviewDirty = appviewUrl !== savedAppviewUrl; 68 + 69 + const breadcrumbs = <span>Settings</span>; 70 + 71 + if (!did || !handle || !pdsUrl) { 72 + return ( 73 + <PanelShell depth={0} breadcrumbs={breadcrumbs} footer=""> 74 + <div className="text-base-content/50 flex h-full items-center justify-center"> 75 + Log in to view settings 76 + </div> 77 + </PanelShell> 78 + ); 79 + } 80 + 10 81 return ( 11 - <div className="flex flex-1 flex-col gap-6 overflow-auto p-6"> 12 - <h1 className="text-base-content text-lg font-semibold">Settings</h1> 13 - <div className="text-base-content/60 space-y-1 text-sm"> 14 - {did && ( 15 - <p> 16 - DID: <span className="font-mono text-xs">{did}</span> 82 + <PanelShell depth={0} breadcrumbs={breadcrumbs} footer=""> 83 + <div className="mx-auto max-w-2xl space-y-8 p-6"> 84 + <h1 className="flex items-center gap-2 text-2xl font-bold"> 85 + <GearIcon size={24} /> Settings 86 + </h1> 87 + 88 + {/* Account info */} 89 + <section className="card bg-base-200 space-y-2 p-4"> 90 + <h2 className="flex items-center gap-2 font-semibold"> 91 + <UserIcon size={18} /> Account 92 + </h2> 93 + <div className="space-y-1 text-sm"> 94 + <div> 95 + <span className="text-base-content/60">Handle:</span>{" "} 96 + <span className="font-mono">{handle}</span> 97 + </div> 98 + <div> 99 + <span className="text-base-content/60">DID:</span>{" "} 100 + <span className="font-mono text-xs">{truncateDid(did)}</span> 101 + </div> 102 + <div> 103 + <span className="text-base-content/60">PDS:</span>{" "} 104 + <span className="font-mono text-xs">{pdsUrl}</span> 105 + </div> 106 + </div> 107 + </section> 108 + 109 + {/* AppView URL */} 110 + <section className="card bg-base-200 space-y-3 p-4"> 111 + <h2 className="font-semibold">AppView URL</h2> 112 + <p className="text-base-content/60 text-sm"> 113 + The AppView indexes workspace membership and incoming shares. Leave blank to use the 114 + default. 17 115 </p> 18 - )} 19 - {handle && ( 20 - <p> 21 - Handle: <span className="font-mono text-xs">{handle}</span> 22 - </p> 23 - )} 24 - {pdsUrl && ( 25 - <p> 26 - PDS: <span className="font-mono text-xs">{pdsUrl}</span> 27 - </p> 28 - )} 116 + <div className="flex gap-2"> 117 + <input 118 + type="url" 119 + className="input input-bordered input-sm flex-1" 120 + placeholder="https://appview.opake.app" 121 + value={appviewUrl} 122 + onChange={(e) => setAppviewUrl(e.target.value)} 123 + disabled={saving} 124 + /> 125 + <button 126 + type="button" 127 + className="btn btn-sm btn-primary gap-1.5" 128 + disabled={!appviewDirty || saving} 129 + onClick={handleAppviewSave} 130 + > 131 + <FloppyDiskIcon size={16} /> Save 132 + </button> 133 + </div> 134 + {config && ( 135 + <p className="text-base-content/40 text-xs"> 136 + Last saved: {new Date(config.modifiedAt).toLocaleString()} 137 + </p> 138 + )} 139 + </section> 29 140 </div> 30 - <p className="text-base-content/40 text-sm">Settings — not yet wired to SDK</p> 31 - </div> 141 + </PanelShell> 32 142 ); 33 143 } 34 144 35 145 export const Route = createLazyFileRoute("/cabinet/settings")({ 36 - component: Settings, 146 + component: SettingsPage, 37 147 });
+1
packages/opake-sdk/src/index.ts
··· 34 34 // Domain types 35 35 export { 36 36 type OpakeInitOptions, 37 + type AccountConfig, 37 38 type MutationResult, 38 39 type UploadResult, 39 40 type DownloadResult,
+41
packages/opake-sdk/src/opake.ts
··· 11 11 12 12 import type { Storage } from "./storage"; 13 13 import type { 14 + AccountConfig, 14 15 MutationResult, 15 16 OpakeInitOptions, 16 17 ResolvedIdentity, ··· 675 676 @withTokenGuard 676 677 publishPublicKey(): Promise<string> { 677 678 return this.requireContext().publishPublicKey(); 679 + } 680 + 681 + // --------------------------------------------------------------------------- 682 + // Account config (per-account preferences synced to PDS) 683 + // --------------------------------------------------------------------------- 684 + 685 + /** 686 + * Fetch the account config record (`app.opake.accountConfig/self`), if 687 + * one exists on the PDS. Returns null when the account has never 688 + * written a config. 689 + */ 690 + @wrapWasmErrors 691 + @withTokenGuard 692 + getAccountConfig(): Promise<AccountConfig | null> { 693 + return this.requireContext().getAccountConfig() as Promise<AccountConfig | null>; 694 + } 695 + 696 + /** 697 + * Write (upsert) the account config record. Merges with whatever the 698 + * caller passes — if a field is omitted from `updates`, the current 699 + * stored value is preserved. 700 + * 701 + * @returns The updated config. 702 + */ 703 + @wrapWasmErrors 704 + @withTokenGuard 705 + async updateAccountConfig(updates: Partial<AccountConfig>): Promise<AccountConfig> { 706 + const ctx = this.requireContext(); 707 + const current = ((await ctx.getAccountConfig()) as AccountConfig | null) ?? { 708 + opakeVersion: 1, 709 + telemetryEnabled: false, 710 + modifiedAt: new Date().toISOString(), 711 + }; 712 + const next: AccountConfig = { 713 + ...current, 714 + ...updates, 715 + modifiedAt: new Date().toISOString(), 716 + }; 717 + await ctx.setAccountConfig(next); 718 + return next; 678 719 } 679 720 680 721 // ---------------------------------------------------------------------------
+14
packages/opake-sdk/src/types.ts
··· 4 4 // TypeScript interfaces. The SDK validates WASM output and returns 5 5 // these typed values — consumers never see raw JsValue. 6 6 7 + /** 8 + * Per-account config synced to the user's PDS as a singleton record 9 + * under `app.opake.accountConfig/self`. Holds non-sensitive preferences 10 + * that should follow the account across devices. 11 + */ 12 + export interface AccountConfig { 13 + readonly opakeVersion: number; 14 + readonly telemetryEnabled: boolean; 15 + /** Override the default appview. Leave undefined to use the built-in default. */ 16 + readonly appviewUrl?: string; 17 + /** ISO-8601 timestamp of last write. */ 18 + readonly modifiedAt: string; 19 + } 20 + 7 21 /** Result of a mutation that may be applied directly or proposed for owner approval. */ 8 22 export interface MutationResult { 9 23 /** URI of the created/updated record (null for proposals on other owners' PDS). */