Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"db:generate": "drizzle-kit generate",
"db:pull": "tsx ./scripts/drizzle_pull.ts",
"db:studio": "drizzle-kit studio",
"generate-api": "tsx ./scripts/generate_riven_api.ts"
"generate-api": "tsx ./scripts/generate_riven_api.ts",
"test": "vitest run"
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
Expand Down Expand Up @@ -73,7 +74,8 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.51.0",
"vaul-svelte": "1.0.0-next.7",
"vite": "^7.3.0"
"vite": "^7.3.0",
"vitest": "^4.0.16"
},
"dependencies": {
"@better-auth/passkey": "^1.4.9",
Expand Down
248 changes: 243 additions & 5 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions src/lib/components/backend-error.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import * as Alert from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import AlertTriangle from "@lucide/svelte/icons/alert-triangle";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";

interface Props {
title?: string;
message: string;
hint?: string;
onRetry?: () => void;
}

let {
title = "Unable to Connect",
message,
hint = "Please check that the Riven backend is running and accessible.",
onRetry
}: Props = $props();

function handleRetry() {
if (onRetry) {
onRetry();
} else {
window.location.reload();
}
}
</script>

<div class="flex h-full items-center justify-center p-8">
<Alert.Root variant="destructive" class="max-w-md">
<AlertTriangle class="h-5 w-5" />
<Alert.Title class="text-lg">{title}</Alert.Title>
<Alert.Description class="mt-2 space-y-4">
<p>{message}</p>
{#if hint}
<p class="text-sm opacity-80">{hint}</p>
{/if}
<Button onclick={handleRetry} variant="outline" size="sm" class="mt-2 gap-2">
<RefreshCw class="h-4 w-4" />
Try Again
</Button>
</Alert.Description>
</Alert.Root>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { setThemeContext } from "@sjsf/shadcn4-theme";
import * as components from "$lib/components/ui";
import * as extraComponents from "$lib/components/ui/extras";

export function setShadcnContext() {
export function setSettingsFormContext() {
setThemeContext({
components: {
...components,
...extraComponents
...extraComponents,
Checkbox: components.Switch
}
});
}
138 changes: 138 additions & 0 deletions src/lib/components/settings/advanced-section.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<script lang="ts">
import { Field, getValueSnapshot, setValue, type Schema } from "@sjsf/form";
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { exportSettings } from "./export-settings";
import { toast } from "svelte-sonner";
import FileDown from "@lucide/svelte/icons/file-down";
import CollapsibleCard from "./collapsible-card.svelte";
import { getSchemaAtPath, getServiceFields } from "./schema-utils";
import type { SettingsFormState, AppSettings } from "./form-defaults";
import type { SettingsSection } from "./settings-sections";

interface Props {
form: SettingsFormState;
schema: Schema;
section: SettingsSection;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars -- props passed for interface consistency
let { form, schema, section: _section }: Props = $props();

type FieldPath = Parameters<typeof Field>[1]["path"];

const formValue = $derived(getValueSnapshot(form) as AppSettings);

// Get descriptions from schema
const loggingSchema = $derived(getSchemaAtPath(schema, "logging"));
const streamSchema = $derived(getSchemaAtPath(schema, "stream"));
const indexerSchema = $derived(getSchemaAtPath(schema, "indexer"));
const subtitleSchema = $derived(getSchemaAtPath(schema, "post_processing.subtitle"));

// Get field lists from schema
const loggingFields = $derived(getServiceFields(schema, "logging"));
const streamFields = $derived(getServiceFields(schema, "stream"));
const indexerFields = $derived(getServiceFields(schema, "indexer"));
const subtitleFields = $derived(getServiceFields(schema, "post_processing.subtitle"));

// Section states
const logging = $derived(formValue?.logging);
const subtitle = $derived(formValue?.post_processing?.subtitle);

function toggleLogging(enabled: boolean) {
setValue(form, {
logging: { ...logging, enabled }
} as AppSettings);
}

function toggleSubtitle(enabled: boolean) {
setValue(form, {
post_processing: {
...formValue?.post_processing,
subtitle: { ...subtitle, enabled }
}
} as AppSettings);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

function handleExport() {
try {
const settings = getValueSnapshot(form);
const timestamp = new Date().toISOString().slice(0, 10);
exportSettings(settings, `riven-settings-${timestamp}.json`);
toast.success("Settings exported");
} catch (error) {
console.error("Failed to export settings:", error);
toast.error("Failed to export settings");
}
}
</script>

<div class="space-y-4">
<!-- File Logging -->
<CollapsibleCard
title={loggingSchema?.title || "File Logging"}
description={loggingSchema?.description}
hasToggle
enabled={logging?.enabled ?? false}
onToggle={toggleLogging}
contentClass="grid gap-4 sm:grid-cols-2">
{#each loggingFields as fieldName (fieldName)}
<Field {form} path={["logging", fieldName] as FieldPath} />
{/each}
</CollapsibleCard>

<!-- Streaming -->
<CollapsibleCard
title={streamSchema?.title || "Streaming"}
description={streamSchema?.description}
contentClass="grid gap-4 sm:grid-cols-2">
{#each streamFields as fieldName (fieldName)}
<Field {form} path={["stream", fieldName] as FieldPath} />
{/each}
</CollapsibleCard>

<!-- Indexer (not collapsible) -->
<Card.Root>
<Card.Header class="pb-2">
<Card.Title class="text-base">{indexerSchema?.title || "Indexer"}</Card.Title>
{#if indexerSchema?.description}
<Card.Description>{indexerSchema.description}</Card.Description>
{/if}
</Card.Header>
<Card.Content class="space-y-4">
{#each indexerFields as fieldName (fieldName)}
<Field {form} path={["indexer", fieldName] as FieldPath} />
{/each}
</Card.Content>
</Card.Root>

<!-- Subtitles -->
<CollapsibleCard
title={subtitleSchema?.title || "Subtitles"}
description={subtitleSchema?.description}
hasToggle
enabled={subtitle?.enabled ?? false}
onToggle={toggleSubtitle}
contentClass="space-y-4">
{#each subtitleFields as fieldName (fieldName)}
<Field {form} path={["post_processing", "subtitle", fieldName] as FieldPath} />
{/each}
</CollapsibleCard>

<!-- Export -->
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Export Settings</Card.Title>
<Card.Description>
Download your settings as a JSON file. Sensitive values (API keys, tokens) will be
redacted.
</Card.Description>
</Card.Header>
<Card.Content>
<Button variant="outline" onclick={handleExport}>
<FileDown class="mr-2 h-4 w-4" />
Export Settings
</Button>
</Card.Content>
</Card.Root>
</div>
101 changes: 101 additions & 0 deletions src/lib/components/settings/collapsible-card.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card";
import { Switch } from "$lib/components/ui/switch";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import type { Snippet } from "svelte";

interface Props {
title: string;
description?: string;
hasToggle?: boolean;
enabled?: boolean;
onToggle?: (enabled: boolean) => void;
/** Initial expanded state - if hasToggle is true and no explicit value, defaults to enabled state */
defaultExpanded?: boolean;
/** Content to render inside the card */
children: Snippet;
/** Additional CSS classes for the Card.Content wrapper */
contentClass?: string;
}

let {
title,
description,
hasToggle = false,
enabled = false,
onToggle,
defaultExpanded,
children,
contentClass = ""
}: Props = $props();

// Track if user has manually overridden the expanded state
let userExpandedState = $state<boolean | null>(null);

// Compute effective expanded state:
// - If user has set a preference, use that
// - Otherwise, if hasToggle, follow enabled state
// - Otherwise use defaultExpanded (defaults to true for non-toggle cards)
const expanded = $derived(
userExpandedState ?? (hasToggle ? enabled : (defaultExpanded ?? true))
);

function setExpanded(value: boolean) {
userExpandedState = value;
}

function handleToggle(checked: boolean) {
onToggle?.(checked);
// When enabling, reset user state so it follows enabled
if (checked) userExpandedState = null;
}

// If enabled becomes true and user had collapsed it, reset to follow enabled
$effect(() => {
if (enabled && userExpandedState === false) {
userExpandedState = null;
}
});
</script>

<Card.Root class="overflow-hidden">
<div
role="button"
tabindex="0"
class="hover:bg-muted/50 flex w-full cursor-pointer items-center justify-between p-4 text-left transition-colors"
onclick={() => setExpanded(!expanded)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpanded(!expanded);
}
}}>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<div class="flex items-center gap-3">
{#if hasToggle}
<span role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()}>
<Switch checked={enabled} onCheckedChange={handleToggle} />
</span>
{/if}
<div>
<h4 class="font-medium">{title}</h4>
{#if description && (!hasToggle || !enabled)}
<p class="text-muted-foreground text-sm">{description}</p>
{/if}
</div>
</div>
<ChevronDown
class="text-muted-foreground h-5 w-5 transition-transform duration-200 {expanded
? 'rotate-180'
: ''}" />
</div>

<div
class="grid transition-[grid-template-rows] duration-200 ease-out"
style="grid-template-rows: {expanded ? '1fr' : '0fr'}">
<div class="overflow-hidden">
<Card.Content class="border-t pt-4 {contentClass}">
{@render children()}
</Card.Content>
</div>
</div>
</Card.Root>
66 changes: 66 additions & 0 deletions src/lib/components/settings/export-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const SENSITIVE_KEY_PATTERNS = [
/api[_-]?key/i,
/token/i,
/secret/i,
/password/i,
/credential/i,
/auth/i,
/private/i,
/bearer/i,
/[_-]?key$/i, // Matches camelCase (apiKey, privateKey) and snake_case (api_key)
/^rss$/i // RSS feeds can contain auth tokens
];

// Matches connection strings with embedded credentials: scheme://user:password@host
const CONNECTION_STRING_PATTERN = /^[a-z][a-z0-9+.-]*:\/\/[^:]+:[^@]+@/i;

function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
}

function isSensitiveValue(value: string): boolean {
return CONNECTION_STRING_PATTERN.test(value);
}

function obfuscateObject(obj: unknown, parentKey = ""): unknown {
if (obj === null || obj === undefined) {
return obj;
}

if (Array.isArray(obj)) {
return obj.map((item, index) => obfuscateObject(item, `${parentKey}[${index}]`));
}

if (typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string" && value.length > 0) {
if (isSensitiveKey(key) || isSensitiveValue(value)) {
result[key] = `redacted-${key}`;
} else {
result[key] = value;
}
Comment thread
hiitsrob marked this conversation as resolved.
Outdated
} else {
result[key] = obfuscateObject(value, key);
}
}
return result;
}

return obj;
}

export function exportSettings(settings: unknown, filename = "riven-settings.json"): void {
const obfuscated = obfuscateObject(settings);
const json = JSON.stringify(obfuscated, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);

const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
Loading