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
199 lines
9.5 KiB
TypeScript
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 ?? "—"} · {user?.email ?? "—"} · {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>
|
|
);
|
|
}
|