Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
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
269 changes: 264 additions & 5 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,11 @@
transform: rotate(360deg);
}
}

@layer components {
/* Settings form styling - card appearance for nested config sections.
:has() targets fieldsets containing nested fieldsets - requires Safari 15.4+ */
.settings-form fieldset:has(> fieldset) > fieldset {
@apply border-border bg-card/50 mb-4 rounded-lg border p-4;
}
}
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>
226 changes: 118 additions & 108 deletions src/lib/components/notification-center.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { notificationStore } from "$lib/stores/notifications.svelte";
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
Expand Down Expand Up @@ -83,126 +84,135 @@
});
</script>

<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
size="icon"
class="relative h-10 w-10 cursor-pointer"
{...props}>
{#if notificationStore.unreadCount > 0}
<BellRing class="size-5" />
{:else}
<Bell class="size-5" />
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-96 p-0" align="end">
<div class="flex flex-col">
<div class="flex items-center justify-between p-4 pb-3">
<h3 class="text-sm font-semibold">Notifications</h3>
<div class="flex items-center gap-2">
{#if browser}
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
size="icon"
class="relative h-10 w-10 cursor-pointer"
{...props}>
{#if notificationStore.unreadCount > 0}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
onclick={handleMarkAllAsRead}>
<CheckCheck class="mr-1 size-3" />
Mark all read
</Button>
{/if}
{#if notificationStore.notifications.length > 0}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
onclick={handleClearAll}>
<X class="mr-1 size-3" />
Clear all
</Button>
<BellRing class="size-5" />
{:else}
<Bell class="size-5" />
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-96 p-0" align="end">
<div class="flex flex-col">
<div class="flex items-center justify-between p-4 pb-3">
<h3 class="text-sm font-semibold">Notifications</h3>
<div class="flex items-center gap-2">
{#if notificationStore.unreadCount > 0}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
onclick={handleMarkAllAsRead}>
<CheckCheck class="mr-1 size-3" />
Mark all read
</Button>
{/if}
{#if notificationStore.notifications.length > 0}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
onclick={handleClearAll}>
<X class="mr-1 size-3" />
Clear all
</Button>
{/if}
</div>
</div>
</div>
<Separator />
<Separator />

<div class="max-h-100 overflow-y-auto">
{#if notificationStore.notifications.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<Bell class="text-muted-foreground/30 size-12" />
<p class="text-muted-foreground mt-2 text-sm">No notifications yet</p>
<p class="text-muted-foreground/70 mt-1 text-xs">
You'll be notified when items complete
</p>
</div>
{:else}
{#each notificationStore.notifications as notification (notification.id)}
<div
class="border-border/50 hover:bg-muted/30 border-b p-3 transition-colors {!notification.read
? 'bg-muted/10'
: ''}">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<Badge
variant="secondary"
class="{getTypeColor(notification.type)} text-[10px]">
{notification.type}
</Badge>
<div class="max-h-100 overflow-y-auto">
{#if notificationStore.notifications.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<Bell class="text-muted-foreground/30 size-12" />
<p class="text-muted-foreground mt-2 text-sm">No notifications yet</p>
<p class="text-muted-foreground/70 mt-1 text-xs">
You'll be notified when items complete
</p>
</div>
{:else}
{#each notificationStore.notifications as notification (notification.id)}
<div
class="border-border/50 hover:bg-muted/30 border-b p-3 transition-colors {!notification.read
? 'bg-muted/10'
: ''}">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<Badge
variant="secondary"
class="{getTypeColor(
notification.type
)} text-[10px]">
{notification.type}
</Badge>
{#if !notification.read}
<div class="size-2 rounded-full bg-blue-500"></div>
{/if}
</div>
<p class="text-sm leading-none font-medium">
{notification.title}
</p>
<p class="text-muted-foreground text-xs">
{notification.message}
</p>
<p class="text-muted-foreground/70 text-xs">
{formatTimestamp(notification.timestamp)}
</p>
</div>
<div class="flex flex-col gap-1">
{#if !notification.read}
<div class="size-2 rounded-full bg-blue-500"></div>
<Button
variant="ghost"
size="icon"
class="size-6"
onclick={() => handleMarkAsRead(notification.id)}>
<Check class="size-3" />
</Button>
{/if}
</div>
<p class="text-sm leading-none font-medium">
{notification.title}
</p>
<p class="text-muted-foreground text-xs">
{notification.message}
</p>
<p class="text-muted-foreground/70 text-xs">
{formatTimestamp(notification.timestamp)}
</p>
</div>
<div class="flex flex-col gap-1">
{#if !notification.read}
<Button
variant="ghost"
size="icon"
class="size-6"
onclick={() => handleMarkAsRead(notification.id)}>
<Check class="size-3" />
onclick={() => handleRemove(notification.id)}>
<X class="size-3" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
class="size-6"
onclick={() => handleRemove(notification.id)}>
<X class="size-3" />
</Button>
</div>
</div>
</div>
</div>
{/each}
{/each}
{/if}
</div>

{#if notificationStore.connectionStatus === "error"}
<div class="border-destructive/20 bg-destructive/10 border-t p-3">
<p class="text-destructive text-xs">
Connection error. Notifications may be delayed.
</p>
<Button
variant="outline"
size="sm"
class="mt-2 h-6 text-xs"
onclick={() => notificationStore.reconnect()}>
Reconnect
</Button>
</div>
{/if}
</div>

{#if notificationStore.connectionStatus === "error"}
<div class="border-destructive/20 bg-destructive/10 border-t p-3">
<p class="text-destructive text-xs">
Connection error. Notifications may be delayed.
</p>
<Button
variant="outline"
size="sm"
class="mt-2 h-6 text-xs"
onclick={() => notificationStore.reconnect()}>
Reconnect
</Button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
</Popover.Content>
</Popover.Root>
{:else}
<!-- SSR placeholder -->
<Button variant="outline" size="icon" class="relative h-10 w-10">
<Bell class="size-5" />
</Button>
{/if}
Comment thread
hiitsrob marked this conversation as resolved.
42 changes: 31 additions & 11 deletions src/lib/components/settings/form-defaults.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,59 @@
export { translation } from "@sjsf/form/translations/en";
import type { FormState, ValidatorFactoryOptions } from "@sjsf/form";
import type { components } from "$lib/providers/riven";

export type AppSettings = components["schemas"]["AppModel"];
export type SettingsFormState = FormState<AppSettings>;

export { resolver } from "@sjsf/form/resolvers/basic";
// Side-effect imports: register additional field types
import "@sjsf/form/fields/extra/enum-include";
import "@sjsf/form/fields/extra/multi-enum-include";
import "@sjsf/form/fields/extra/unknown-native-file-include";

export { theme } from "@sjsf/shadcn4-theme";
import "@sjsf/shadcn4-theme/extra-widgets/textarea-include";
import "@sjsf/shadcn4-theme/extra-widgets/checkboxes-include";
import "@sjsf/shadcn4-theme/extra-widgets/radio-include";
import "@sjsf/shadcn4-theme/extra-widgets/file-include";
import "@sjsf/shadcn4-theme/extra-widgets/date-picker-include";
// import "@sjsf-lab/shadcn-extras-theme/extra-widgets/password-include";
// import "@sjsf-lab/shadcn-extras-theme/extra-widgets/tags-input-include";

export { createFormIdBuilder as idBuilder } from "@sjsf/sveltekit";
// Theme with custom widgets
import { theme as baseTheme } from "@sjsf/shadcn4-theme";
import { extendByRecord } from "@sjsf/form/lib/resolver";
import { CustomRankWidget, NullableArrayWidget, NullablePrimitiveWidget } from "./widgets";

export { createFormMerger as merger } from "@sjsf/form/mergers/modern";
export const theme = extendByRecord(baseTheme, {
customRankWidget: CustomRankWidget,
nullableArrayWidget: NullableArrayWidget,
nullablePrimitiveWidget: NullablePrimitiveWidget
});

import type { ValidatorFactoryOptions } from "@sjsf/form";
// Validator with custom formats
import { addFormComponents, createFormValidator } from "@sjsf/ajv8-validator";
import addFormats from "ajv-formats";

/** Matches paths starting with / or . (absolute or relative filesystem paths) */
const PATH_FORMAT_REGEX = /^[/.].*/;

/**
* Permissive regex for multi-host URI fields - accepts any non-empty string.
* Intended to allow flexible formats: single host, host:port, comma-separated hosts, etc.
* This is a placeholder; tighten validation here if stricter format enforcement is needed.
*/
const MULTI_HOST_URI_REGEX = /^.+$/;

export const validator = <T>(options: ValidatorFactoryOptions) =>
createFormValidator<T>({
...options,
ajvPlugins: (ajv) => {
addFormComponents(addFormats(ajv));

ajv.addFormat("path", PATH_FORMAT_REGEX);
ajv.addFormat("multi-host-uri", MULTI_HOST_URI_REGEX);

return ajv;
}
});

// Form utilities
export { translation } from "@sjsf/form/translations/en";
// Use compat resolver (not basic) because it handles enum fields correctly.
// Basic resolver maps enums to stringField (text input); compat returns enumField (select).
export { resolver } from "@sjsf/form/resolvers/compat";
export { createFormIdBuilder as idBuilder } from "@sjsf/sveltekit";
export { createFormMerger as merger } from "@sjsf/form/mergers/modern";
Loading