feat: Add new UI components for various features including recording, notes, and analytics, and refactor integration configuration panels.

This commit is contained in:
2026-01-19 22:16:48 +00:00
parent 362a1a4dd7
commit 1835dcae92
144 changed files with 1277 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
// Animated transcription component for streaming text display
// Implements word-staggered materialization effect with entity highlighting
import { useState, useCallback, memo } from 'react';
import { useAnimatedWords } from '@/hooks/ui/use-animated-words';
import { findMatchingEntities } from '@/lib/state/entities';
import { cn } from '@/lib/utils';
import { HighlightedTerm } from './entity-highlight';
import type { Entity } from '@/types/entity';
/** Match result from findMatchingEntities */
interface EntityMatch {
entity: Entity;
startIndex: number;
endIndex: number;
}
export interface AnimatedTranscriptionProps {
/** The text to display with animation */
text: string;
/** Unique identifier for animation tracking */
blockId: string;
/** Stagger delay between words in milliseconds. Default: 80 */
staggerDelay?: number;
/** Set of pinned entity IDs for highlighting */
pinnedEntities: Set<string>;
/** Callback when an entity is toggled */
onTogglePin: (entityId: string) => void;
/** Additional CSS classes */
className?: string;
/** Whether to show the typing cursor. Default: false */
showCursor?: boolean;
}
interface AnimatedWordProps {
word: string;
shouldAnimate: boolean;
delay: number;
entityMatch: EntityMatch | null;
pinnedEntities: Set<string>;
onTogglePin: (entityId: string) => void;
}
/**
* Single animated word component with two-phase entity highlighting.
* Phase 1: Word materializes (blur → brightness pop → settle)
* Phase 2: Entity highlight fades in after animation completes
*/
const AnimatedWord = memo(function AnimatedWord({
word,
shouldAnimate,
delay,
entityMatch,
pinnedEntities,
onTogglePin,
}: AnimatedWordProps) {
const [animationComplete, setAnimationComplete] = useState(!shouldAnimate);
const handleAnimationEnd = useCallback(() => {
setAnimationComplete(true);
}, []);
// Determine CSS classes based on animation state
const animationClass = shouldAnimate
? 'animate-reveal-chunk'
: 'animate-reveal-complete';
// Render entity highlight only after animation completes
if (entityMatch && animationComplete) {
return (
<span
className={cn('inline', animationClass)}
style={shouldAnimate ? { animationDelay: `${delay}ms` } : undefined}
onAnimationEnd={handleAnimationEnd}
>
<HighlightedTerm
text={word}
entity={entityMatch.entity}
pinnedEntities={pinnedEntities}
onTogglePin={onTogglePin}
className="animate-entity-highlight"
/>
</span>
);
}
// Render plain word with animation
return (
<span
className={cn('inline', animationClass)}
style={shouldAnimate ? { animationDelay: `${delay}ms` } : undefined}
onAnimationEnd={handleAnimationEnd}
>
{word}
</span>
);
});
AnimatedWord.displayName = 'AnimatedWord';
/**
* Animated transcription display component.
*
* Features:
* - Word-staggered materialization animation (blur → brightness → settle)
* - Incremental animation (only new words animate)
* - Two-phase entity highlighting (animate first, then highlight)
* - Optional typing cursor
*/
export const AnimatedTranscription = memo(function AnimatedTranscription({
text,
blockId,
staggerDelay = 80,
pinnedEntities,
onTogglePin,
className,
showCursor = false,
}: AnimatedTranscriptionProps) {
const wordStates = useAnimatedWords(text, { staggerDelay, blockId });
if (!text) {
return null;
}
// Find entity matches for the full text
const entityMatches = findMatchingEntities(text);
// Build a map of word positions to entity matches
const getEntityMatchForWord = (word: string, wordStartPos: number): EntityMatch | null => {
const wordEndPos = wordStartPos + word.length;
for (const match of entityMatches) {
// Check if this word overlaps with an entity match
if (wordStartPos >= match.startIndex && wordEndPos <= match.endIndex) {
return match;
}
}
return null;
};
// Calculate word positions in the original text
let currentPos = 0;
const wordsWithPositions = text.split(/(\s+)/).map((segment) => {
const startPos = currentPos;
currentPos += segment.length;
return { segment, startPos };
});
return (
<span className={cn('inline', className)}>
{wordsWithPositions.map(({ segment, startPos }, idx) => {
// Handle whitespace
if (/^\s+$/.test(segment)) {
return <span key={`ws-${idx}`}>{segment}</span>;
}
// Find corresponding word state
const wordState = wordStates.find((ws) => ws.word === segment && ws.index === idx);
if (!wordState) {
// Fallback for words not in state (shouldn't happen)
return <span key={`word-${idx}`}>{segment}</span>;
}
const entityMatch = getEntityMatchForWord(segment, startPos);
return (
<AnimatedWord
key={`word-${idx}-${blockId}`}
word={wordState.word}
shouldAnimate={wordState.shouldAnimate}
delay={wordState.delay}
entityMatch={entityMatch}
pinnedEntities={pinnedEntities}
onTogglePin={onTogglePin}
/>
);
})}
{showCursor && (
<span className="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse" />
)}
</span>
);
});
AnimatedTranscription.displayName = 'AnimatedTranscription';

View File

@@ -0,0 +1,79 @@
/**
* OAuth/SSO authentication configuration.
*/
import { Globe, Key, Lock } from 'lucide-react';
import type { Integration } from '@/api/types';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { configPanelContentStyles, Field, SecretInput } from './shared';
interface AuthConfigProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
showSecrets: Record<string, boolean>;
toggleSecret: (key: string) => void;
}
export function AuthConfig({ integration, onUpdate, showSecrets, toggleSecret }: AuthConfigProps) {
const config = integration.oauth_config || {
client_id: '',
client_secret: '',
redirect_uri: '',
scopes: [],
};
return (
<div className={configPanelContentStyles}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
<Input
value={config.client_id}
onChange={(e) => onUpdate({ oauth_config: { ...config, client_id: e.target.value } })}
placeholder="Enter client ID"
/>
</Field>
<SecretInput
label="Client Secret"
value={config.client_secret}
onChange={(value) => onUpdate({ oauth_config: { ...config, client_secret: value } })}
placeholder="Enter client secret"
showSecret={showSecrets.client_secret ?? false}
onToggleSecret={() => toggleSecret('client_secret')}
icon={<Lock className="h-4 w-4" />}
/>
</div>
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
<Input
value={config.redirect_uri}
onChange={(e) => onUpdate({ oauth_config: { ...config, redirect_uri: e.target.value } })}
placeholder="https://your-app.com/auth/callback"
/>
<p className="text-xs text-muted-foreground">
Configure this URL in your OAuth provider's settings
</p>
</Field>
<div className="space-y-2">
<Label className="text-sm">Scopes</Label>
<Input
value={config.scopes.join(', ')}
onChange={(e) =>
onUpdate({
oauth_config: {
...config,
scopes: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="openid, email, profile"
/>
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* Calendar integration configuration.
*/
import { Globe, Key, Lock } from 'lucide-react';
import type { Integration } from '@/api/types';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { useWorkspace } from '@/contexts/workspace-state';
import { configPanelContentStyles, Field, SecretInput } from './shared';
interface CalendarConfigProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
showSecrets: Record<string, boolean>;
toggleSecret: (key: string) => void;
}
export function CalendarConfig({
integration,
onUpdate,
showSecrets,
toggleSecret,
}: CalendarConfigProps) {
const { currentWorkspace } = useWorkspace();
const calConfig = integration.calendar_config || {
sync_interval_minutes: 15,
calendar_ids: [],
};
const oauthConfig = integration.oauth_config || {
client_id: '',
client_secret: '',
redirect_uri: '',
scopes: [],
};
const overrideEnabled = integration.oauth_override_enabled ?? false;
const overrideHasSecret = integration.oauth_override_has_secret ?? false;
const canOverride =
currentWorkspace?.role === 'owner' || currentWorkspace?.role === 'admin';
const oauthFieldsDisabled = !overrideEnabled || !canOverride;
return (
<div className={configPanelContentStyles}>
<div className="flex items-center gap-2">
<Badge variant="secondary">OAuth 2.0</Badge>
<span className="text-xs text-muted-foreground">Requires OAuth authentication</span>
</div>
<div className="flex items-center justify-between rounded-md border border-border bg-background/50 p-3">
<div className="space-y-1">
<Label className="text-sm">Use custom OAuth credentials</Label>
<p className="text-xs text-muted-foreground">
{overrideEnabled
? 'Using custom credentials for this workspace'
: 'Using server-provided credentials'}
</p>
{!canOverride ? (
<p className="text-xs text-muted-foreground">Admin access required</p>
) : null}
</div>
<Switch
checked={overrideEnabled}
onCheckedChange={(value) => onUpdate({ oauth_override_enabled: value })}
disabled={!canOverride}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
<Input
value={oauthConfig.client_id}
onChange={(e) =>
onUpdate({
oauth_config: { ...oauthConfig, client_id: e.target.value },
})
}
placeholder="Enter client ID"
disabled={oauthFieldsDisabled}
/>
</Field>
<SecretInput
label="Client Secret"
value={oauthConfig.client_secret}
onChange={(value) => onUpdate({ oauth_config: { ...oauthConfig, client_secret: value } })}
placeholder={overrideHasSecret ? 'Stored on server' : 'Enter client secret'}
showSecret={showSecrets.calendar_client_secret ?? false}
onToggleSecret={() => toggleSecret('calendar_client_secret')}
icon={<Lock className="h-4 w-4" />}
disabled={oauthFieldsDisabled}
/>
</div>
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
<Input
value={oauthConfig.redirect_uri}
onChange={(e) =>
onUpdate({
oauth_config: { ...oauthConfig, redirect_uri: e.target.value },
})
}
placeholder="https://your-app.com/calendar/callback"
disabled={oauthFieldsDisabled}
/>
</Field>
<Separator />
<div className="space-y-2">
<Label className="text-sm">Sync Interval (minutes)</Label>
<Select
value={String(calConfig.sync_interval_minutes || 15)}
onValueChange={(value) =>
onUpdate({
calendar_config: { ...calConfig, sync_interval_minutes: parseInt(value, 10) },
})
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5 minutes</SelectItem>
<SelectItem value="15">15 minutes</SelectItem>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Calendar IDs (optional)</Label>
<Input
value={calConfig.calendar_ids?.join(', ') || ''}
onChange={(e) =>
onUpdate({
calendar_config: {
...calConfig,
calendar_ids: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="primary, work@example.com"
/>
<p className="text-xs text-muted-foreground">
Leave empty to sync all calendars, or specify calendar IDs
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Webhook URL (optional)</Label>
<Input
value={calConfig.webhook_url || ''}
onChange={(e) =>
onUpdate({
calendar_config: { ...calConfig, webhook_url: e.target.value },
})
}
placeholder="https://your-app.com/webhooks/calendar"
/>
<p className="text-xs text-muted-foreground">Receive real-time calendar updates</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
/**
* Email provider configuration.
*/
import { Key, Lock, Mail, Server } from 'lucide-react';
import type { Integration } from '@/api/types';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { IntegrationDefaults } from '@/lib/config';
import { configPanelContentStyles, Field, SecretInput, TestButton } from './shared';
interface EmailConfigProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
onTest?: () => void;
isTesting: boolean;
showSecrets: Record<string, boolean>;
toggleSecret: (key: string) => void;
}
export function EmailConfig({
integration,
onUpdate,
onTest,
isTesting,
showSecrets,
toggleSecret,
}: EmailConfigProps) {
const config = integration.email_config || {
provider_type: 'api' as const,
api_key: '',
from_email: '',
from_name: '',
};
return (
<div className={configPanelContentStyles}>
<div className="space-y-2">
<Label className="text-sm">Provider Type</Label>
<Select
value={config.provider_type}
onValueChange={(value: 'smtp' | 'api') =>
onUpdate({
email_config: { ...config, provider_type: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api">API (SendGrid, Resend, etc.)</SelectItem>
<SelectItem value="smtp">SMTP Server</SelectItem>
</SelectContent>
</Select>
</div>
{config.provider_type === 'api' ? (
<SecretInput
label="API Key"
value={config.api_key || ''}
onChange={(value) => onUpdate({ email_config: { ...config, api_key: value } })}
placeholder="Enter your API key"
showSecret={showSecrets.email_api_key ?? false}
onToggleSecret={() => toggleSecret('email_api_key')}
icon={<Key className="h-4 w-4" />}
/>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="SMTP Host" icon={<Server className="h-4 w-4" />}>
<Input
value={config.smtp_host || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, smtp_host: e.target.value },
})
}
placeholder="smtp.example.com"
/>
</Field>
<div className="space-y-2">
<Label className="text-sm">Port</Label>
<Input
type="number"
value={config.smtp_port || IntegrationDefaults.SMTP_PORT}
onChange={(e) =>
onUpdate({
email_config: {
...config,
smtp_port: parseInt(e.target.value, 10) || IntegrationDefaults.SMTP_PORT,
},
})
}
placeholder={String(IntegrationDefaults.SMTP_PORT)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm">Username</Label>
<Input
value={config.smtp_username || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, smtp_username: e.target.value },
})
}
placeholder="username@example.com"
/>
</div>
<SecretInput
label="Password"
value={config.smtp_password || ''}
onChange={(value) => onUpdate({ email_config: { ...config, smtp_password: value } })}
placeholder="SMTP password"
showSecret={showSecrets.smtp_password ?? false}
onToggleSecret={() => toggleSecret('smtp_password')}
icon={<Lock className="h-4 w-4" />}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.smtp_secure ?? true}
onCheckedChange={(checked) =>
onUpdate({
email_config: { ...config, smtp_secure: checked },
})
}
/>
<Label className="text-sm">Use TLS/SSL</Label>
</div>
</>
)}
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Field label="From Email" icon={<Mail className="h-4 w-4" />}>
<Input
type="email"
value={config.from_email || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, from_email: e.target.value },
})
}
placeholder="noreply@example.com"
/>
</Field>
<div className="space-y-2">
<Label className="text-sm">From Name</Label>
<Input
value={config.from_name || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, from_name: e.target.value },
})
}
placeholder="NoteFlow"
/>
</div>
</div>
<TestButton onTest={onTest} isTesting={isTesting} label="Send Test Email" Icon={Mail} />
</div>
);
}

View File

@@ -0,0 +1,118 @@
/**
* Integration Configuration Panel Component.
*
* Renders configuration forms based on integration type.
* Split into separate components for maintainability.
*/
import { useState } from 'react';
import type { Integration } from '@/api/types';
import { AuthConfig } from './auth-config';
import { CalendarConfig } from './calendar-config';
import { EmailConfig } from './email-config';
import { OIDCConfig } from './oidc-config';
import { PKMConfig } from './pkm-config';
import { WebhookConfig } from './webhook-config';
export interface IntegrationConfigPanelProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
onTest?: () => void;
isTesting?: boolean;
}
export function IntegrationConfigPanel({
integration,
onUpdate,
onTest,
isTesting = false,
}: IntegrationConfigPanelProps) {
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const toggleSecret = (key: string) => setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
// OAuth/SSO Configuration
if (integration.type === 'auth') {
return (
<AuthConfig
integration={integration}
onUpdate={onUpdate}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// Email Configuration
if (integration.type === 'email') {
return (
<EmailConfig
integration={integration}
onUpdate={onUpdate}
onTest={onTest}
isTesting={isTesting}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// Calendar Configuration
if (integration.type === 'calendar') {
return (
<CalendarConfig
integration={integration}
onUpdate={onUpdate}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// PKM Configuration
if (integration.type === 'pkm') {
return (
<PKMConfig
integration={integration}
onUpdate={onUpdate}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// Custom/Webhook Configuration
if (integration.type === 'custom') {
return (
<WebhookConfig
integration={integration}
onUpdate={onUpdate}
onTest={onTest}
isTesting={isTesting}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// OIDC Provider Configuration
if (integration.type === 'oidc') {
return (
<OIDCConfig
integration={integration}
onUpdate={onUpdate}
onTest={onTest}
isTesting={isTesting}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
return (
<div className="py-4 text-sm text-muted-foreground">
No configuration options available for this integration.
</div>
);
}

View File

@@ -0,0 +1,183 @@
/**
* OIDC provider configuration.
*/
import { Globe, Key, Lock } from 'lucide-react';
import type { Integration } from '@/api/types';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { formatTimestamp } from '@/lib/utils/format';
import { Field, SecretInput, TestButton } from './shared';
interface OIDCConfigProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
onTest?: () => void;
isTesting: boolean;
showSecrets: Record<string, boolean>;
toggleSecret: (key: string) => void;
}
export function OIDCConfig({
integration,
onUpdate,
onTest,
isTesting,
showSecrets,
toggleSecret,
}: OIDCConfigProps) {
const config = integration.oidc_config || {
preset: 'custom' as const,
issuer_url: '',
client_id: '',
client_secret: '',
scopes: ['openid', 'profile', 'email'],
claim_mapping: {
subject_claim: 'sub',
email_claim: 'email',
email_verified_claim: 'email_verified',
name_claim: 'name',
preferred_username_claim: 'preferred_username',
groups_claim: 'groups',
picture_claim: 'picture',
},
require_email_verified: true,
allowed_groups: [],
};
return (
<div className="space-y-4 pt-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">OIDC</Badge>
<span className="text-xs text-muted-foreground">OpenID Connect Provider</span>
</div>
<div className="space-y-2">
<Label className="text-sm">Provider Preset</Label>
<Select
value={config.preset}
onValueChange={(value) =>
onUpdate({
oidc_config: { ...config, preset: value as typeof config.preset },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="authentik">Authentik</SelectItem>
<SelectItem value="authelia">Authelia</SelectItem>
<SelectItem value="keycloak">Keycloak</SelectItem>
<SelectItem value="auth0">Auth0</SelectItem>
<SelectItem value="okta">Okta</SelectItem>
<SelectItem value="azure_ad">Azure AD / Entra ID</SelectItem>
<SelectItem value="custom">Custom OIDC</SelectItem>
</SelectContent>
</Select>
</div>
<Field label="Issuer URL" icon={<Globe className="h-4 w-4" />}>
<Input
value={config.issuer_url}
onChange={(e) => onUpdate({ oidc_config: { ...config, issuer_url: e.target.value } })}
placeholder="https://auth.example.com"
/>
<p className="text-xs text-muted-foreground">
Base URL for OIDC discovery (/.well-known/openid-configuration)
</p>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
<Input
value={config.client_id}
onChange={(e) => onUpdate({ oidc_config: { ...config, client_id: e.target.value } })}
placeholder="noteflow-client"
/>
</Field>
<SecretInput
label="Client Secret"
value={config.client_secret || ''}
onChange={(value) => onUpdate({ oidc_config: { ...config, client_secret: value } })}
placeholder="Enter client secret"
showSecret={showSecrets.oidc_client_secret ?? false}
onToggleSecret={() => toggleSecret('oidc_client_secret')}
icon={<Lock className="h-4 w-4" />}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Scopes</Label>
<Input
value={config.scopes.join(', ')}
onChange={(e) =>
onUpdate({
oidc_config: {
...config,
scopes: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="openid, profile, email, groups"
/>
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.require_email_verified}
onCheckedChange={(checked) =>
onUpdate({
oidc_config: { ...config, require_email_verified: checked },
})
}
/>
<Label className="text-sm">Require verified email</Label>
</div>
{config.discovery && (
<div className="rounded-md bg-muted/50 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Discovery Endpoints</p>
<div className="text-xs space-y-1">
<p>
<span className="text-muted-foreground">Authorization:</span>{' '}
{config.discovery.authorization_endpoint}
</p>
<p>
<span className="text-muted-foreground">Token:</span>{' '}
{config.discovery.token_endpoint}
</p>
{config.discovery.userinfo_endpoint && (
<p>
<span className="text-muted-foreground">UserInfo:</span>{' '}
{config.discovery.userinfo_endpoint}
</p>
)}
</div>
{config.discovery_refreshed_at && (
<p className="text-xs text-muted-foreground">
Last refreshed: {formatTimestamp(config.discovery_refreshed_at)}
</p>
)}
</div>
)}
<TestButton onTest={onTest} isTesting={isTesting} label="Test OIDC Connection" />
</div>
);
}

View File

@@ -0,0 +1,126 @@
/**
* Personal Knowledge Management (PKM) configuration.
*/
import { Database, FolderOpen, Key } from 'lucide-react';
import type { Integration } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { EXTERNAL_LINK_REL } from '@/lib/ui/styles';
import { Field, SecretInput } from './shared';
interface PKMConfigProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
showSecrets: Record<string, boolean>;
toggleSecret: (key: string) => void;
}
export function PKMConfig({ integration, onUpdate, showSecrets, toggleSecret }: PKMConfigProps) {
const config = integration.pkm_config || { api_key: '', workspace_id: '', sync_enabled: false };
const isNotion = integration.name.toLowerCase().includes('notion');
const isObsidian = integration.name.toLowerCase().includes('obsidian');
return (
<div className="space-y-4 pt-2">
{isNotion && (
<>
<SecretInput
label="Integration Token"
value={config.api_key || ''}
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
placeholder="secret_xxxxxxxxxxxxxxxx"
showSecret={showSecrets.notion_token ?? false}
onToggleSecret={() => toggleSecret('notion_token')}
icon={<Key className="h-4 w-4" />}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel={EXTERNAL_LINK_REL}
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
<Field label="Database ID" icon={<Database className="h-4 w-4" />}>
<Input
value={config.database_id || ''}
onChange={(e) =>
onUpdate({
pkm_config: { ...config, database_id: e.target.value },
})
}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
<p className="text-xs text-muted-foreground">The ID from your Notion database URL</p>
</Field>
</>
)}
{isObsidian && (
<Field label="Vault Path" icon={<FolderOpen className="h-4 w-4" />}>
<div className="flex gap-2">
<Input
value={config.vault_path || ''}
onChange={(e) =>
onUpdate({
pkm_config: { ...config, vault_path: e.target.value },
})
}
placeholder="/path/to/obsidian/vault"
className="flex-1"
/>
<Button variant="outline" size="icon">
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Path to your Obsidian vault folder</p>
</Field>
)}
{!isNotion && !isObsidian && (
<>
<SecretInput
label="API Key"
value={config.api_key || ''}
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
placeholder="Enter API key"
showSecret={showSecrets.pkm_api_key ?? false}
onToggleSecret={() => toggleSecret('pkm_api_key')}
icon={<Key className="h-4 w-4" />}
/>
<div className="space-y-2">
<Label className="text-sm">Workspace ID</Label>
<Input
value={config.workspace_id || ''}
onChange={(e) =>
onUpdate({
pkm_config: { ...config, workspace_id: e.target.value },
})
}
placeholder="Enter workspace ID"
/>
</div>
</>
)}
<div className="flex items-center gap-2">
<Switch
checked={config.sync_enabled ?? false}
onCheckedChange={(checked) =>
onUpdate({
pkm_config: { ...config, sync_enabled: checked },
})
}
/>
<Label className="text-sm">Enable auto-sync</Label>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
/**
* Shared components for integration configuration panels.
*/
import { Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { iconWithMargin, labelStyles } from '@/lib/ui/styles';
/** Common container styles for config panel content sections. */
export const configPanelContentStyles = 'space-y-4 pt-2';
/**
* Reusable form field wrapper with label and icon.
*/
export function Field({
label,
icon,
children,
}: {
label: string;
icon?: ReactNode;
children: ReactNode;
}) {
return (
<div className="space-y-2">
<Label className={labelStyles.withIcon}>
{icon}
{label}
</Label>
{children}
</div>
);
}
/**
* Secret input field with show/hide toggle.
*/
export function SecretInput({
label,
value,
onChange,
placeholder,
showSecret,
onToggleSecret,
icon,
disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
showSecret: boolean;
onToggleSecret: () => void;
icon?: ReactNode;
disabled?: boolean;
}) {
return (
<Field label={label} icon={icon}>
<div className="relative">
<Input
type={showSecret ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="pr-10"
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={onToggleSecret}
disabled={disabled}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</Field>
);
}
/**
* Test connection button.
*/
export function TestButton({
onTest,
isTesting,
label = 'Test Connection',
Icon = RefreshCw,
}: {
onTest?: () => void;
isTesting?: boolean;
label?: string;
Icon?: React.ElementType;
}) {
if (!onTest) {
return null;
}
return (
<Button variant="outline" onClick={onTest} disabled={isTesting}>
{isTesting ? (
<Loader2 className={`${iconWithMargin.md} animate-spin`} />
) : (
<Icon className={iconWithMargin.md} />
)}
{label}
</Button>
);
}

View File

@@ -0,0 +1,121 @@
/**
* Custom/Webhook integration configuration.
*/
import { Globe, Key } from 'lucide-react';
import type { Integration } from '@/api/types';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Field, SecretInput, TestButton } from './shared';
interface WebhookConfigProps {
integration: Integration;
onUpdate: (config: Partial<Integration>) => void;
onTest?: () => void;
isTesting: boolean;
showSecrets: Record<string, boolean>;
toggleSecret: (key: string) => void;
}
export function WebhookConfig({
integration,
onUpdate,
onTest,
isTesting,
showSecrets,
toggleSecret,
}: WebhookConfigProps) {
const config = integration.webhook_config || {
url: '',
method: 'POST' as const,
auth_type: 'none' as const,
auth_value: '',
};
return (
<div className="space-y-4 pt-2">
<Field label="Webhook URL" icon={<Globe className="h-4 w-4" />}>
<Input
value={config.url}
onChange={(e) =>
onUpdate({
webhook_config: { ...config, url: e.target.value },
})
}
placeholder="https://api.example.com/webhook"
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm">HTTP Method</Label>
<Select
value={config.method}
onValueChange={(value: 'GET' | 'POST' | 'PUT') =>
onUpdate({
webhook_config: { ...config, method: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Authentication</Label>
<Select
value={config.auth_type || 'none'}
onValueChange={(value: 'none' | 'bearer' | 'basic' | 'api_key') =>
onUpdate({
webhook_config: { ...config, auth_type: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="api_key">API Key Header</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{config.auth_type && config.auth_type !== 'none' && (
<SecretInput
label={
config.auth_type === 'bearer'
? 'Bearer Token'
: config.auth_type === 'basic'
? 'Credentials (user:pass)'
: 'API Key'
}
value={config.auth_value || ''}
onChange={(value) => onUpdate({ webhook_config: { ...config, auth_value: value } })}
placeholder={config.auth_type === 'basic' ? 'username:password' : 'Enter value'}
showSecret={showSecrets.webhook_auth ?? false}
onToggleSecret={() => toggleSecret('webhook_auth')}
icon={<Key className="h-4 w-4" />}
/>
)}
<TestButton onTest={onTest} isTesting={isTesting} label="Test Webhook" />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More