mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 01:16:23 +02:00
Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev
This commit is contained in:
commit
82db06d724
6 changed files with 274 additions and 49 deletions
|
|
@ -64,6 +64,8 @@ function createWindow() {
|
|||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
show: false, // Don't show until ready
|
||||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
|
|
@ -73,6 +75,11 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
// Show window when content is ready to prevent blank screen
|
||||
win.once("ready-to-show", () => {
|
||||
win.show();
|
||||
});
|
||||
|
||||
// Open external links in system browser (not sandboxed Electron window)
|
||||
// This handles window.open() and target="_blank" links
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,22 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rowboat</title>
|
||||
<style>
|
||||
/* Prevent flash of white background before CSS loads */
|
||||
html, body { margin: 0; padding: 0; }
|
||||
html.dark, html.dark body { background-color: #252525; }
|
||||
html.light, html.light body { background-color: #fff; }
|
||||
</style>
|
||||
<script>
|
||||
// Apply theme class immediately before render
|
||||
(function() {
|
||||
var stored = localStorage.getItem('rowboat-theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var theme = stored || 'system';
|
||||
var resolved = theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;
|
||||
document.documentElement.classList.add(resolved);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@
|
|||
--sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0));
|
||||
--sidebar-border: var(--sub-alt-color, oklch(0.922 0 0));
|
||||
--sidebar-ring: var(--main-color, oklch(0.708 0 0));
|
||||
--scrollbar-track: oklch(0.95 0 0);
|
||||
--scrollbar-thumb: oklch(0.75 0 0);
|
||||
--scrollbar-thumb-hover: oklch(0.65 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -157,15 +160,39 @@
|
|||
--sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%));
|
||||
--sidebar-ring: var(--main-color, oklch(0.556 0 0));
|
||||
--scrollbar-track: oklch(0.2 0 0);
|
||||
--scrollbar-thumb: oklch(0.4 0 0);
|
||||
--scrollbar-thumb-hover: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown content base styles for Streamdown/MessageResponse */
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Server, Key, Shield } from "lucide-react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,14 +11,15 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTheme, type Theme } from "@/contexts/theme-context"
|
||||
|
||||
type ConfigTab = "models" | "mcp" | "security"
|
||||
type ConfigTab = "models" | "mcp" | "security" | "appearance"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
path: string
|
||||
path?: string
|
||||
description: string
|
||||
}
|
||||
|
||||
|
|
@ -44,12 +45,82 @@ const tabs: TabConfig[] = [
|
|||
path: "config/security.json",
|
||||
description: "Configure allowed shell commands",
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function ThemeOption({
|
||||
label,
|
||||
icon: Icon,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("size-6", isSelected ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className={cn("text-sm font-medium", isSelected ? "text-primary" : "text-foreground")}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceSettings() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Theme</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Select your preferred color scheme
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ThemeOption
|
||||
label="Light"
|
||||
icon={Sun}
|
||||
isSelected={theme === "light"}
|
||||
onClick={() => setTheme("light")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="Dark"
|
||||
icon={Moon}
|
||||
isSelected={theme === "dark"}
|
||||
onClick={() => setTheme("dark")}
|
||||
/>
|
||||
<ThemeOption
|
||||
label="System"
|
||||
icon={Monitor}
|
||||
isSelected={theme === "system"}
|
||||
onClick={() => setTheme("system")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
|
||||
|
|
@ -60,9 +131,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const activeTabConfig = tabs.find((t) => t.id === activeTab)!
|
||||
const isConfigTab = activeTab !== "appearance"
|
||||
|
||||
const loadConfig = async (tab: ConfigTab) => {
|
||||
const formatJson = (jsonString: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonString), null, 2)
|
||||
} catch {
|
||||
return jsonString
|
||||
}
|
||||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
|
|
@ -72,16 +154,17 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
const formattedContent = formatJson(result.data)
|
||||
setContent(formattedContent)
|
||||
setOriginalContent(formattedContent)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(`Failed to load ${tabConfig.label} config`)
|
||||
setContent("")
|
||||
setOriginalContent("")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!isConfigTab || !activeTabConfig.path) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
|
|
@ -103,14 +186,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const formatJson = (jsonString: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonString), null, 2)
|
||||
} catch {
|
||||
return jsonString
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormat = () => {
|
||||
setContent(formatJson(content))
|
||||
}
|
||||
|
|
@ -118,10 +193,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
const hasChanges = content !== originalContent
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (open && activeTab !== "appearance") {
|
||||
loadConfig(activeTab)
|
||||
}
|
||||
}, [open, activeTab])
|
||||
}, [open, activeTab, loadConfig])
|
||||
|
||||
const handleTabChange = (tab: ConfigTab) => {
|
||||
if (hasChanges) {
|
||||
|
|
@ -173,9 +248,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
{loading ? (
|
||||
{activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
|
|
@ -190,36 +267,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{error && (
|
||||
<span className="text-xs text-destructive">{error}</span>
|
||||
)}
|
||||
{hasChanges && !error && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
{/* Footer - only show for config tabs */}
|
||||
{isConfigTab && (
|
||||
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{error && (
|
||||
<span className="text-xs text-destructive">{error}</span>
|
||||
)}
|
||||
{hasChanges && !error && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveConfig}
|
||||
disabled={loading || saving || !hasChanges}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveConfig}
|
||||
disabled={loading || saving || !hasChanges}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
93
apps/x/apps/renderer/src/contexts/theme-context.tsx
Normal file
93
apps/x/apps/renderer/src/contexts/theme-context.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
|
||||
type ThemeContextProps = {
|
||||
theme: Theme
|
||||
resolvedTheme: "light" | "dark"
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
||||
|
||||
const STORAGE_KEY = "rowboat-theme"
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light"
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = React.useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider.")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
defaultTheme = "system",
|
||||
children,
|
||||
}: {
|
||||
defaultTheme?: Theme
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
if (typeof window === "undefined") return defaultTheme
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||
return stored || defaultTheme
|
||||
})
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||
if (theme === "system") return getSystemTheme()
|
||||
return theme
|
||||
})
|
||||
|
||||
// Apply theme to document
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(resolved)
|
||||
setResolvedTheme(resolved)
|
||||
}, [theme])
|
||||
|
||||
// Listen for system theme changes
|
||||
React.useEffect(() => {
|
||||
if (theme !== "system") return
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handleChange = () => {
|
||||
const resolved = getSystemTheme()
|
||||
document.documentElement.classList.remove("light", "dark")
|
||||
document.documentElement.classList.add(resolved)
|
||||
setResolvedTheme(resolved)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
return () => mediaQuery.removeEventListener("change", handleChange)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = React.useCallback((newTheme: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, newTheme)
|
||||
setThemeState(newTheme)
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo<ThemeContextProps>(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme]
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { PostHogProvider } from 'posthog-js/react'
|
||||
import { ThemeProvider } from '@/contexts/theme-context'
|
||||
|
||||
const options = {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
|
|
@ -12,7 +13,9 @@ const options = {
|
|||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
|
||||
<App />
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue