Initial release v1.0.0
This commit is contained in:
74
frontend/features/settings/components/ui/ConnectionCard.tsx
Normal file
74
frontend/features/settings/components/ui/ConnectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
63
frontend/features/settings/components/ui/SettingsInput.tsx
Normal file
63
frontend/features/settings/components/ui/SettingsInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
127
frontend/features/settings/components/ui/SettingsLayout.tsx
Normal file
127
frontend/features/settings/components/ui/SettingsLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
30
frontend/features/settings/components/ui/SettingsRow.tsx
Normal file
30
frontend/features/settings/components/ui/SettingsRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
37
frontend/features/settings/components/ui/SettingsSection.tsx
Normal file
37
frontend/features/settings/components/ui/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
45
frontend/features/settings/components/ui/SettingsSelect.tsx
Normal file
45
frontend/features/settings/components/ui/SettingsSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
72
frontend/features/settings/components/ui/SettingsSidebar.tsx
Normal file
72
frontend/features/settings/components/ui/SettingsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
32
frontend/features/settings/components/ui/SettingsToggle.tsx
Normal file
32
frontend/features/settings/components/ui/SettingsToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
9
frontend/features/settings/components/ui/index.ts
Normal file
9
frontend/features/settings/components/ui/index.ts
Normal 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";
|
||||
|
||||
Reference in New Issue
Block a user