diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9f94603..798354a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -373,6 +373,7 @@ export const api = { port: number; activePort?: number; startOnBoot: boolean; + projectDirectory?: string; }> => { const endpoints = [ "/api/system/server-config", @@ -388,6 +389,7 @@ export const api = { port: number; activePort?: number; startOnBoot: boolean; + projectDirectory?: string; }; return { online: true, @@ -395,6 +397,7 @@ export const api = { port: data.port, ...(data.activePort !== undefined ? { activePort: data.activePort } : {}), startOnBoot: data.startOnBoot, + ...(data.projectDirectory ? { projectDirectory: data.projectDirectory } : {}), }; } } catch { @@ -410,7 +413,12 @@ export const api = { }, updateServerConfig: async ( - patch: { enabled?: boolean; port?: number; startOnBoot?: boolean }, + patch: { + enabled?: boolean; + port?: number; + startOnBoot?: boolean; + projectDirectory?: string; + }, portHint?: number, ): Promise<{ online: boolean; @@ -418,6 +426,7 @@ export const api = { port: number; activePort?: number; startOnBoot: boolean; + projectDirectory?: string; }> => { const endpoints = [ "/api/system/server-config", @@ -438,6 +447,7 @@ export const api = { port: number; activePort?: number; startOnBoot: boolean; + projectDirectory?: string; }; return { online: true, @@ -445,6 +455,7 @@ export const api = { port: data.port, ...(data.activePort !== undefined ? { activePort: data.activePort } : {}), startOnBoot: data.startOnBoot, + ...(data.projectDirectory ? { projectDirectory: data.projectDirectory } : {}), }; } } catch { @@ -456,6 +467,103 @@ export const api = { enabled: patch.enabled ?? true, port: patch.port ?? portHint ?? 8081, startOnBoot: patch.startOnBoot ?? false, + ...(patch.projectDirectory ? { projectDirectory: patch.projectDirectory } : {}), }; }, + + listSystemDirectories: async ( + dirPath?: string, + portHint?: number, + ): Promise<{ + online: boolean; + currentPath: string; + parentPath: string | null; + homePath: string; + directories: { name: string; path: string }[]; + }> => { + const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : ""; + const endpoints = [ + `/api/system/list-dirs${query}`, + ...(portHint ? [`http://localhost:${portHint}/api/system/list-dirs${query}`] : []), + ]; + for (const url of endpoints) { + try { + const res = await fetch(url, { credentials: "include" }); + const ct = res.headers.get("content-type") ?? ""; + if (res.ok && ct.includes("application/json")) { + const data = (await res.json()) as { + currentPath: string; + parentPath: string | null; + homePath: string; + directories: { name: string; path: string }[]; + }; + return { + online: true, + currentPath: data.currentPath, + parentPath: data.parentPath, + homePath: data.homePath, + directories: data.directories, + }; + } + } catch { + /* try next */ + } + } + return { + online: false, + currentPath: dirPath || "~/Documents/PubScript", + parentPath: null, + homePath: "~", + directories: [], + }; + }, + + saveExportToDirectory: async ( + params: { + directory: string; + fileName: string; + data: Uint8Array | string; + }, + portHint?: number, + ): Promise<{ savedOnServer: boolean; savedPath?: string }> => { + const bytes = + typeof params.data === "string" + ? new TextEncoder().encode(params.data) + : params.data; + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + const base64Data = btoa(binary); + + const endpoints = [ + "/api/system/save-export", + ...(portHint ? [`http://localhost:${portHint}/api/system/save-export`] : []), + ]; + for (const url of endpoints) { + try { + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directory: params.directory, + fileName: params.fileName, + base64Data, + }), + }); + const ct = res.headers.get("content-type") ?? ""; + if (res.ok && ct.includes("application/json")) { + const out = (await res.json()) as { ok?: boolean; savedPath?: string }; + if (out.ok && out.savedPath) { + return { savedOnServer: true, savedPath: out.savedPath }; + } + } + } catch { + /* try next */ + } + } + return { savedOnServer: false }; + }, }; diff --git a/apps/web/src/components/DirectoryBrowserModal.tsx b/apps/web/src/components/DirectoryBrowserModal.tsx new file mode 100644 index 0000000..414edaa --- /dev/null +++ b/apps/web/src/components/DirectoryBrowserModal.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { Modal, Button } from "@pubscript/ui"; +import { api } from "../api.js"; +import { useSettings } from "../store/settings.js"; + +export interface DirectoryBrowserModalProps { + open: boolean; + initialPath: string; + title?: string; + onSelect: (selectedPath: string) => void; + onClose: () => void; +} + +export function DirectoryBrowserModal({ + open, + initialPath, + title = "Select Directory", + onSelect, + onClose, +}: DirectoryBrowserModalProps) { + const serverPort = useSettings((s) => s.serverPort); + const [currentPath, setCurrentPath] = useState(initialPath || "~/Documents/PubScript"); + const [parentPath, setParentPath] = useState(null); + const [homePath, setHomePath] = useState("~"); + const [directories, setDirectories] = useState<{ name: string; path: string }[]>([]); + const [loading, setLoading] = useState(false); + + const loadPath = async (targetPath: string) => { + setLoading(true); + try { + const res = await api.listSystemDirectories(targetPath, serverPort); + setCurrentPath(res.currentPath); + setParentPath(res.parentPath); + setHomePath(res.homePath); + setDirectories(res.directories); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!open) return; + void loadPath(initialPath || "~/Documents/PubScript"); + }, [open, initialPath]); + + return ( + + + + + } + > +
+
+ setCurrentPath(e.target.value)} + placeholder="/path/to/directory" + /> + +
+ +
+ {parentPath && ( + + )} + + + +
+ +
+ {loading ? ( +
+ Loading folders… +
+ ) : directories.length === 0 ? ( +
+ No subfolders in this directory (you can click “Select This Folder” or enter any path above). +
+ ) : ( + directories.map((dir) => ( + + )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx index b2ab7af..c430d9d 100644 --- a/apps/web/src/components/Settings.tsx +++ b/apps/web/src/components/Settings.tsx @@ -6,6 +6,7 @@ import { type AppSettings, } from "../store/settings.js"; import { api } from "../api.js"; +import { DirectoryBrowserModal } from "./DirectoryBrowserModal.js"; import { Select, Button, @@ -19,6 +20,34 @@ import { } from "@pubscript/ui"; import type { ProviderKind, ThinkingEffort } from "@pubscript/ai"; +type SettingsTab = "appearance" | "writing" | "ai" | "server"; + +const ACCENT_PRESETS = [ + "#7c8bf5", + "#9d7bff", + "#3b6ef6", + "#88c0d0", + "#52c48c", + "#33ff9b", + "#d98a4e", + "#b5793a", + "#ff4fd8", + "#f16d7a", + "#f0b754", +]; + +const TEXT_COLOR_PRESETS = [ + { label: "Soft White", color: "#e6e8ee" }, + { label: "Pure White", color: "#ffffff" }, + { label: "Warm Cream", color: "#f2e8df" }, + { label: "Lavender Mist", color: "#e8e6ff" }, + { label: "Terminal Green", color: "#33ff9b" }, + { label: "Amber Phosphor", color: "#ffb84d" }, + { label: "Charcoal Ink", color: "#2c2720" }, + { label: "Deep Slate", color: "#1a202c" }, + { label: "Vintage Sepia", color: "#362819" }, +]; + export function Settings() { const s = useSettings(); const set = useSettings((st) => st.set); @@ -32,12 +61,17 @@ export function Settings() { const probeProviderModels = useSettings((st) => st.probeProviderModels); const reset = useSettings((st) => st.reset); + const [activeTab, setActiveTab] = useState("appearance"); const [selectedProviderId, setSelectedProviderId] = useState(s.activeProviderId); const [selectedAgentId, setSelectedAgentId] = useState(s.activeAgentId); const [probing, setProbing] = useState(false); - // Server settings state + // Server & Project Directory state const [portDraft, setPortDraft] = useState(String(s.serverPort)); + const [projectDirDraft, setProjectDirDraft] = useState( + s.projectDirectory || "~/Documents/PubScript", + ); + const [browseProjectDirOpen, setBrowseProjectDirOpen] = useState(false); const [serverOnline, setServerOnline] = useState(false); const [activeRunningPort, setActiveRunningPort] = useState(null); const [savingServerCfg, setSavingServerCfg] = useState(false); @@ -50,10 +84,14 @@ export function Settings() { setActiveRunningPort(res.activePort ?? res.port); if (res.online) { setPortDraft(String(res.port)); + if (res.projectDirectory) { + setProjectDirDraft(res.projectDirectory); + } set({ serverEnabled: res.enabled, serverPort: res.port, serverStartOnBoot: res.startOnBoot, + ...(res.projectDirectory ? { projectDirectory: res.projectDirectory } : {}), }); } }); @@ -62,6 +100,29 @@ export function Settings() { }; }, []); + const currentProvider = + s.aiProviders.find((p) => p.id === selectedProviderId) ?? s.aiProviders[0]!; + const currentAgent = + s.aiAgents.find((a) => a.id === selectedAgentId) ?? s.aiAgents[0]!; + + const activeThemeDefaultText = + THEME_TOKENS[s.theme]?.css["--ps-text"] ?? "#e6e8ee"; + const activeThemeDefaultAccent = + THEME_TOKENS[s.theme]?.css["--ps-accent"] ?? "#7c8bf5"; + + const handleSaveProjectDirectory = async (dirPath?: string) => { + const target = (dirPath ?? projectDirDraft).trim() || "~/Documents/PubScript"; + setProjectDirDraft(target); + set({ projectDirectory: target }); + setSavingServerCfg(true); + try { + await api.updateServerConfig({ projectDirectory: target }, s.serverPort); + toast.success(`Main project directory set to ${target}`); + } finally { + setSavingServerCfg(false); + } + }; + const handleToggleServerEnabled = async (enabled: boolean) => { set({ serverEnabled: enabled }); setSavingServerCfg(true); @@ -112,11 +173,6 @@ export function Settings() { } }; - const currentProvider = - s.aiProviders.find((p) => p.id === selectedProviderId) ?? s.aiProviders[0]!; - const currentAgent = - s.aiAgents.find((a) => a.id === selectedAgentId) ?? s.aiAgents[0]!; - const handleProbeModels = async () => { if (!currentProvider) return; setProbing(true); @@ -166,563 +222,865 @@ export function Settings() { const def = DEFAULT_AI_AGENTS.find((a) => a.id === currentAgent.id); if (def) { updateAgent(currentAgent.id, { - systemPrompt: def.systemPrompt, temperature: def.temperature, contextWindow: def.contextWindow, maxTokens: def.maxTokens, thinkingEnabled: def.thinkingEnabled, + thinkingEffort: def.thinkingEffort, + systemPrompt: def.systemPrompt, }); - toast.info(`Reset ${currentAgent.name} to default settings`); + toast.info(`Reset ${def.name} to default settings`); } }; return ( -
-
-

Settings

-

- Configure appearance, UI density, AI providers & agents, and writing preferences. - Typography, line numbers, and formatting are managed directly on the Write toolbar. -

+
+
+

Settings

+
+ {( + [ + { id: "appearance", label: "🎨 Appearance & Themes" }, + { id: "writing", label: "📁 Projects & Writing" }, + { id: "ai", label: "✨ AI Providers & Agents" }, + { id: "server", label: "🖧 Server & System" }, + ] as const + ).map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
- {/* APPEARANCE */} -
-

Appearance & Layout

+ {/* TAB 1: APPEARANCE & THEMES */} + {activeTab === "appearance" && ( +
+

Appearance & Themes

-
-
-
Theme
-
- Choose a color theme for the studio. UI typography is locked to System Sans. +
+
+
Theme
+
+ Choose from 10 studio palettes crafted for long-form writing. +
-
-
set({ density: e.target.value as Density })} - options={[ - { value: "compact", label: "Compact (High density)" }, - { value: "comfortable", label: "Comfortable (Standard spacing)" }, - ]} - /> -
-
- - {/* AI PROVIDERS & AGENTS */} -
-

AI Providers & Connection

- -
-
-
- updateProvider(currentProvider.id, { name: e.target.value })} - /> - updateProvider(currentProvider.id, { baseUrl: e.target.value })} - /> - updateProvider(currentProvider.id, { apiKey: e.target.value })} - /> - { - const model = e.target.value; - const models = currentProvider.models.includes(model) - ? currentProvider.models - : [model, ...currentProvider.models].filter(Boolean); - updateProvider(currentProvider.id, { defaultModel: model, models }); - if (s.activeProviderId === currentProvider.id) { - set({ activeModel: model }); - } - }} - /> - { - const models = e.target.value - .split(",") - .map((m) => m.trim()) - .filter(Boolean); - updateProvider(currentProvider.id, { - models: models.length > 0 ? models : [currentProvider.defaultModel], - }); - }} - /> -
-
-
- - {/* AI AGENTS */} -
-

AI Agents & Role Prompts

- -
-
-
- updateAgent(currentAgent.id, { name: e.target.value })} - /> -
- - - updateAgent(currentAgent.id, { temperature: Number(e.target.value) }) - } - /> - - Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming - -
- - updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) }) - } - options={[ - { value: "512", label: "512 tokens" }, - { value: "1024", label: "1,024 tokens" }, - { value: "2048", label: "2,048 tokens" }, - { value: "4096", label: "4,096 tokens" }, - { value: "8192", label: "8,192 tokens" }, - ]} - /> -
- -
+ +
+
+
Word count goal
+
Target length for your manuscript.
+
+ set({ autoSave: e.target.checked })} + /> + {s.autoSave ? "Enabled" : "Disabled"} + +
+ +
+
+
Spellcheck
+
+ Underline misspelled words as you write using the bundled offline dictionary. +
+
+ +
+ + {s.enableSpellcheck && ( +
+
+
Spellcheck Language
+
Active dictionary for spelling checks.
+
set({ enableGrammarCheck: e.target.checked })} + /> + {s.enableGrammarCheck ? "Enabled" : "Disabled"} +
-
- -