Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
import { ReactNode } from "react";
import Image from "next/image";
interface ConnectionCardProps {
icon: string | ReactNode;
title: string;
description?: string;
connected: boolean;
connectedAs?: string;
onConnect: () => void;
onDisconnect: () => void;
isLoading?: boolean;
}
export function ConnectionCard({
icon,
title,
description,
connected,
connectedAs,
onConnect,
onDisconnect,
isLoading
}: ConnectionCardProps) {
return (
<div className="flex items-center justify-between py-4 px-4 bg-[#1a1a1a] rounded-lg">
<div className="flex items-center gap-3">
{/* Icon */}
<div className="w-10 h-10 rounded-full bg-[#282828] flex items-center justify-center overflow-hidden">
{typeof icon === "string" ? (
<Image
src={icon}
alt={title}
width={24}
height={24}
className="w-6 h-6"
/>
) : (
icon
)}
</div>
{/* Text */}
<div>
<div className="text-sm font-medium text-white">{title}</div>
{connected && connectedAs ? (
<div className="text-xs text-gray-400">
Connected as <span className="text-white">{connectedAs}</span>
</div>
) : description ? (
<div className="text-xs text-gray-500">{description}</div>
) : null}
</div>
</div>
{/* Action Button */}
<button
onClick={connected ? onDisconnect : onConnect}
disabled={isLoading}
className={`
px-4 py-1.5 text-sm font-medium rounded-full transition-colors
${isLoading ? 'opacity-50 cursor-not-allowed' : ''}
${connected
? 'bg-transparent border border-gray-600 text-white hover:border-white hover:scale-105'
: 'bg-white text-black hover:scale-105'
}
`}
>
{isLoading ? "..." : connected ? "Disconnect" : "Connect"}
</button>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
interface SettingsInputProps {
id?: string;
type?: "text" | "password" | "url" | "number";
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export function SettingsInput({
id,
type = "text",
value,
onChange,
placeholder,
disabled,
className = ""
}: SettingsInputProps) {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
return (
<div className={`relative ${className}`}>
<input
id={id}
type={isPassword && showPassword ? "text" : type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`
w-full bg-[#333] text-white text-sm
px-3 py-2 rounded-md
border-0 outline-none
focus:ring-2 focus:ring-white/20
placeholder:text-gray-500
transition-colors
hover:bg-[#404040] focus:bg-[#404040]
${isPassword ? 'pr-10' : ''}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
/>
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { SettingsSidebar, SidebarItem } from "./SettingsSidebar";
interface SettingsLayoutProps {
children: ReactNode;
sidebarItems: SidebarItem[];
isAdmin: boolean;
}
export function SettingsLayout({ children, sidebarItems, isAdmin }: SettingsLayoutProps) {
const [activeSection, setActiveSection] = useState(sidebarItems[0]?.id || "");
const mainContentRef = useRef<HTMLDivElement>(null);
// Handle sidebar click - scroll to section
const handleSectionClick = useCallback((id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveSection(id);
}
}, []);
// Track active section based on scroll position
useEffect(() => {
const visibleItems = sidebarItems.filter(item => !item.adminOnly || isAdmin);
// Find the scrollable parent (the main element in AuthenticatedLayout)
const findScrollableParent = (el: HTMLElement | null): HTMLElement | null => {
while (el) {
const style = window.getComputedStyle(el);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
return el;
}
el = el.parentElement;
}
return null;
};
const scrollContainer = mainContentRef.current
? findScrollableParent(mainContentRef.current)
: null;
if (!scrollContainer) return;
// Use scroll event for smooth tracking
const handleScroll = () => {
const containerRect = scrollContainer.getBoundingClientRect();
const offset = 150; // Offset from top
// Find the section that's currently in view
let currentSection = visibleItems[0]?.id || "";
for (const item of visibleItems) {
const element = document.getElementById(item.id);
if (element) {
const rect = element.getBoundingClientRect();
// Check if element top is above the offset line
if (rect.top <= containerRect.top + offset) {
currentSection = item.id;
}
}
}
setActiveSection(prev => {
if (prev !== currentSection) {
return currentSection;
}
return prev;
});
};
// Throttle scroll events
let ticking = false;
const scrollHandler = () => {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
ticking = true;
}
};
scrollContainer.addEventListener("scroll", scrollHandler, { passive: true });
// Initial check
handleScroll();
return () => scrollContainer.removeEventListener("scroll", scrollHandler);
}, [sidebarItems, isAdmin]);
return (
<div className="min-h-screen bg-[#0a0a0a] relative">
{/* Subtle grey gradient for systems page feel */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: 'linear-gradient(to bottom, #1a1a1a 0%, #121212 15%, #0a0a0a 30%)'
}}
/>
<div className="relative max-w-5xl mx-auto px-4 md:px-8 py-8">
{/* Header */}
<h1 className="text-2xl font-bold text-white mb-8">Settings</h1>
{/* Layout */}
<div className="flex gap-12">
{/* Sidebar */}
<SettingsSidebar
items={sidebarItems}
activeSection={activeSection}
onSectionClick={handleSectionClick}
isAdmin={isAdmin}
/>
{/* Main Content */}
<main ref={mainContentRef} className="flex-1 min-w-0">
{children}
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { ReactNode } from "react";
interface SettingsRowProps {
label: string;
description?: ReactNode;
children: ReactNode;
htmlFor?: string;
}
export function SettingsRow({ label, description, children, htmlFor }: SettingsRowProps) {
return (
<div className="flex items-center justify-between py-3 min-h-[56px]">
<div className="flex-1 pr-4">
<label
htmlFor={htmlFor}
className="text-sm text-white cursor-pointer"
>
{label}
</label>
{description && (
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
)}
</div>
<div className="shrink-0">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { ReactNode } from "react";
interface SettingsSectionProps {
id: string;
title: string;
description?: string;
children: ReactNode;
showSeparator?: boolean;
}
export function SettingsSection({
id,
title,
description,
children,
showSeparator = true
}: SettingsSectionProps) {
return (
<section id={id} className="scroll-mt-24">
<div className="mb-4">
<h2 className="text-base font-semibold text-white">{title}</h2>
{description && (
<p className="text-sm text-gray-400 mt-0.5">{description}</p>
)}
</div>
<div className="space-y-1">
{children}
</div>
{showSeparator && (
<div className="border-t border-white/5 mt-6 mb-6" />
)}
</section>
);
}

View File

@@ -0,0 +1,45 @@
import { ChevronDown } from "lucide-react";
interface Option {
value: string;
label: string;
description?: string;
}
interface SettingsSelectProps {
id?: string;
value: string;
onChange: (value: string) => void;
options: Option[];
disabled?: boolean;
}
export function SettingsSelect({ id, value, onChange, options, disabled }: SettingsSelectProps) {
return (
<div className="relative">
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={`
appearance-none bg-[#333] text-white text-sm
pl-3 pr-8 py-1.5 rounded-md
border-0 outline-none
focus:ring-2 focus:ring-white/20
cursor-pointer transition-colors
hover:bg-[#404040]
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
export interface SidebarItem {
id: string;
label: string;
adminOnly?: boolean;
}
interface SettingsSidebarProps {
items: SidebarItem[];
activeSection: string;
onSectionClick: (id: string) => void;
isAdmin: boolean;
}
export function SettingsSidebar({ items, activeSection, onSectionClick, isAdmin }: SettingsSidebarProps) {
const filteredItems = items.filter(item => !item.adminOnly || isAdmin);
// Group items: regular items first, then admin-only items
const regularItems = filteredItems.filter(item => !item.adminOnly);
const adminItems = filteredItems.filter(item => item.adminOnly);
return (
<nav className="w-48 shrink-0 sticky top-8 self-start hidden md:block">
<div className="space-y-0.5">
{regularItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionClick(item.id)}
className={`
w-full text-left px-3 py-2 rounded-md text-sm transition-colors
${activeSection === item.id
? 'text-white bg-[#282828]'
: 'text-gray-400 hover:text-white'
}
`}
>
{item.label}
</button>
))}
{adminItems.length > 0 && (
<>
<div className="pt-4 pb-2 px-3">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Admin
</span>
</div>
{adminItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionClick(item.id)}
className={`
w-full text-left px-3 py-2 rounded-md text-sm transition-colors
${activeSection === item.id
? 'text-white bg-[#282828]'
: 'text-gray-400 hover:text-white'
}
`}
>
{item.label}
</button>
))}
</>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,32 @@
interface SettingsToggleProps {
id?: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function SettingsToggle({ id, checked, onChange, disabled }: SettingsToggleProps) {
return (
<label className="relative inline-flex items-center cursor-pointer">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only peer"
/>
<div className={`
w-10 h-6 rounded-full transition-colors
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${checked ? 'bg-[#1DB954]' : 'bg-[#404040]'}
peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-[#1DB954]/30
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5
after:transition-transform after:duration-200
${checked ? 'after:translate-x-4' : 'after:translate-x-0'}
`} />
</label>
);
}

View File

@@ -0,0 +1,9 @@
export { SettingsLayout } from "./SettingsLayout";
export { SettingsSidebar, type SidebarItem } from "./SettingsSidebar";
export { SettingsSection } from "./SettingsSection";
export { SettingsRow } from "./SettingsRow";
export { SettingsToggle } from "./SettingsToggle";
export { SettingsSelect } from "./SettingsSelect";
export { SettingsInput } from "./SettingsInput";
export { ConnectionCard } from "./ConnectionCard";