learning_ai_notes/web/src/app/(app)/settings/page.tsx
Saravana Achu Mac a697752d15 feat: implement WEB_AI_FAST_ROADMAP (web + backend + docs)
Phase 1: Command palette (⌘K), editor autosave with quiet auto-saves, dashboard
saved views from API + quick links + onboarding seed CTA, explicit task scan panel.

Phase 2: Context pack formatter with YAML frontmatter, copy on note + workspace .md export.

Phase 3: ADR for hybrid search without embeddings; POST /notes/search (lexical +
ranked hybrid); search UI mode toggle.

Phase 4: POST copilot + suggest-title; in-editor copilot actions; /chat retrieval
answers with citations (backend chat.rag_enabled).

Phase 5: Settings MCP snippet, offline queue note, API token deferral; DEEP_LINKS.md.

Phase 6: Note shares + public GET; share page; POST onboarding-seed.

Phase 7: note_versions on PATCH; version panel; create-note templates; PWA manifest.

Flags: search.hybrid_enabled, copilot.enabled, chat.rag_enabled, onboarding.seed_enabled.
Made-with: Cursor
2026-03-31 13:00:36 -07:00

199 lines
9.5 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTheme } from "@/lib/use-theme";
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/AppShell";
import { getFeedbackClient } from "@/lib/feedback-client";
import { toast } from "@/lib/toast";
import { NOTES_API_URL, PLATFORM_SERVICE_URL, MCP_SERVER_URL, PRODUCT_ID } from "@/lib/product-config";
import { getOfflineQueue } from "@/lib/offline-queue";
export default function SettingsPage() {
const { theme, toggle } = useTheme();
const { user, logout, changePassword, deleteAccount, isLoading, error, success, clearMessages } = useAuth();
const [feedbackTitle, setFeedbackTitle] = useState("");
const [feedbackBody, setFeedbackBody] = useState("");
const [feedbackType, setFeedbackType] = useState<"bug" | "feature" | "praise" | "other">("bug");
const [submittingFeedback, setSubmittingFeedback] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
async function handleSubmitFeedback() {
if (!feedbackTitle.trim()) return;
setSubmittingFeedback(true);
try {
await getFeedbackClient().submitWithScreenshot({
type: feedbackType,
title: feedbackTitle.trim(),
body: feedbackBody.trim() || undefined,
platform: "web",
});
toast.success("Feedback submitted");
setFeedbackTitle("");
setFeedbackBody("");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit feedback");
} finally {
setSubmittingFeedback(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
clearMessages();
const ok = await changePassword(currentPassword, newPassword);
if (ok) {
setCurrentPassword("");
setNewPassword("");
toast.success("Password changed");
}
}
async function handleDeleteAccount() {
const pw = prompt("Enter your password to confirm account deletion:");
if (!pw) return;
const ok = await deleteAccount(pw);
if (ok) toast.success("Account deleted");
}
return (
<AppShell
title="Settings"
description="Account, preferences, feedback, and session management."
actions={
<button onClick={logout} style={{ padding: "6px 14px", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", border: "none", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
Sign out
</button>
}
>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: "var(--nl-space-4)" }}>
{/* Profile */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Profile</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>
{user?.name ?? "—"} &middot; {user?.email ?? "—"} &middot; {user?.role ?? "—"}
</div>
</article>
{/* Appearance */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>Appearance</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>Switch between dark and light mode</div>
</div>
<button
onClick={toggle}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
className="surface-muted"
style={{ padding: "6px 14px", border: "none", fontSize: "var(--nl-fs-sm)" }}
>
{theme === "dark" ? "Light" : "Dark"}
</button>
</article>
{/* Change password */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Change password</strong>
{error && <div style={{ color: "var(--nl-danger, #FF6E6E)", fontSize: "var(--nl-fs-sm)" }}>{error}</div>}
{success && <div style={{ color: "#4ADE80", fontSize: "var(--nl-fs-sm)" }}>{success}</div>}
<form onSubmit={handleChangePassword} style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<input className="input-shell" type="password" placeholder="Current password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required />
<input className="input-shell" type="password" placeholder="New password" minLength={8} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
<button type="submit" disabled={isLoading} style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}>
{isLoading ? "Updating…" : "Update password"}
</button>
</form>
</article>
{/* Danger zone */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Danger zone</strong>
<button onClick={handleDeleteAccount} style={{ padding: "8px 16px", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}>
Delete account
</button>
</article>
</section>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
<strong>Connect your agent (MCP)</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Use your platform access token with the shared MCP server. Point tools at the NoteLett backend and product id{" "}
<code>{PRODUCT_ID}</code>.
</p>
<pre
className="surface-muted"
style={{ margin: 0, padding: "var(--nl-space-3)", fontSize: "var(--nl-fs-sm)", overflow: "auto", whiteSpace: "pre-wrap" }}
>
{`Notes API base: ${NOTES_API_URL}
Platform API: ${PLATFORM_SERVICE_URL}
MCP server (example): ${MCP_SERVER_URL}
# Example Cursor / Claude MCP entry (adjust to your installer):
# "mcpServers": {
# "notelett": { "url": "${MCP_SERVER_URL}" }
# }`}
</pre>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Deep links for tools: see <code>docs/DEEP_LINKS.md</code> in the repo.
</p>
</section>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
<strong>API tokens for automation</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Scoped tokens for MCP or CI are provisioned through the ByteLyst platform when your tenant enables them. This web app does not yet expose
create/revoke; use the platform admin or CLI when available.
</p>
</section>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
<strong>Offline queue</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Failed writes are retried from local storage via <code>@bytelyst/offline-queue</code> (storage key{" "}
<code>{`${PRODUCT_ID}_offline_queue`}</code>). Reload or return online to flush.
</p>
<button
type="button"
className="btn btn-secondary"
style={{ justifySelf: "start" }}
onClick={() => {
try {
getOfflineQueue();
toast.success("Offline queue is available in this build");
} catch {
toast.error("Offline queue unavailable");
}
}}
>
Verify offline queue
</button>
</section>
{/* Feedback */}
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
<strong>Send feedback</strong>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: "var(--nl-space-3)" }}>
<select className="input-shell" value={feedbackType} onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)}>
<option value="bug">Bug</option>
<option value="feature">Feature request</option>
<option value="praise">Praise</option>
<option value="other">Other</option>
</select>
<input className="input-shell" placeholder="Title" value={feedbackTitle} onChange={(e) => setFeedbackTitle(e.target.value)} />
</div>
<textarea className="input-shell" placeholder="Details (optional)" rows={3} value={feedbackBody} onChange={(e) => setFeedbackBody(e.target.value)} style={{ resize: "vertical" }} />
<button
onClick={handleSubmitFeedback}
disabled={submittingFeedback || !feedbackTitle.trim()}
style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}
>
{submittingFeedback ? "Sending…" : "Send feedback"}
</button>
</section>
</AppShell>
);
}