Initial commit

This commit is contained in:
Maurus Decimus
2026-04-20 15:02:57 +02:00
parent e72c9614f1
commit 4470616775
115 changed files with 26207 additions and 21 deletions
@@ -0,0 +1,532 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
import { useSchemaStore } from '@/stores/schemaStore';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { FormEditionContext } from '@/components/forms/FormEditionContext';
import { DefaultLogo } from '@/components/common/Logo';
import { toast } from '@/hooks/use-toast';
import { resolveObject, resolveSchema, resolveForm, buildCreateDefaults, deepMerge } from '@/lib/schemaResolver';
import { calculateJmapPatch } from '@/lib/jmapPatch';
import { jmapGet, jmapSet, getAccountId } from '@/services/jmap/client';
import { friendlySetError } from '@/lib/jmapErrors';
import type { Field, Fields, Form, FormField } from '@/types/schema';
import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
const BOOTSTRAP_VIEW = 'x:Bootstrap';
interface RenderableField {
formField: FormField;
field: Field;
}
export function BootstrapWizard() {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const resolved = useMemo(() => {
if (!schema) return null;
const obj = resolveObject(schema, BOOTSTRAP_VIEW);
if (!obj) return null;
const sch = resolveSchema(schema, obj.objectName);
if (!sch || sch.type !== 'single') return null;
const form = resolveForm(schema, BOOTSTRAP_VIEW, obj.objectName, sch.schemaName);
return { obj, sch, fields: sch.fields, form };
}, [schema]);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
const [currentPage, setCurrentPage] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [generalError, setGeneralError] = useState<string | null>(null);
const [successExtras, setSuccessExtras] = useState<Record<string, unknown> | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const sections = useMemo((): { title?: string; subtitle?: string; fields: RenderableField[] }[] => {
if (!resolved) return [];
const { fields, form } = resolved;
if (!form) return [];
return form.sections
.map((section) => {
const renderableFields: RenderableField[] = [];
for (const ff of section.fields) {
const field = fields.properties[ff.name];
if (!field) continue;
if (field.update === 'serverSet') continue;
if (field.enterprise) continue;
renderableFields.push({ formField: ff, field });
}
return { title: section.title, fields: renderableFields };
})
.filter((s) => s.fields.length > 0);
}, [resolved]);
const fetchProperties = useMemo((): string[] => {
if (!resolved) return ['id'];
const set = new Set<string>(['id']);
for (const section of sections) {
for (const rf of section.fields) set.add(rf.formField.name);
}
return Array.from(set);
}, [resolved, sections]);
useEffect(() => {
if (!schema || !resolved) return;
const { obj, sch } = resolved;
const ctrl = new AbortController();
setLoading(true);
(async () => {
try {
const accountId = getAccountId(obj.objectName);
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
if (ctrl.signal.aborted) return;
const server = (responses[0]?.[1]?.list as Array<Record<string, unknown>> | undefined)?.[0] ?? {};
const defaults = buildCreateDefaults(schema, obj, sch);
const seeded = deepMerge(defaults, server);
setFormData(seeded);
setOriginalData({});
} catch (err) {
if (ctrl.signal.aborted) return;
if (err instanceof DOMException && err.name === 'AbortError') return;
setGeneralError(
err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'),
);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
})();
return () => {
ctrl.abort();
};
}, [schema, resolved, fetchProperties, t]);
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setFieldErrors((prev) => {
if (!prev[fieldName]) return prev;
const copy = { ...prev };
delete copy[fieldName];
return copy;
});
}, []);
const validateSectionRequired = useCallback(
(sectionFields: RenderableField[], data: Record<string, unknown>): Record<string, string> => {
const errors: Record<string, string> = {};
for (const { formField, field } of sectionFields) {
if (field.update === 'serverSet' || field.update === 'immutable') continue;
const fieldType = field.type;
const eligible =
fieldType.type === 'string' ||
fieldType.type === 'number' ||
fieldType.type === 'utcDateTime' ||
fieldType.type === 'enum' ||
fieldType.type === 'blobId' ||
fieldType.type === 'objectId';
if (!eligible) continue;
if ('nullable' in fieldType && fieldType.nullable) continue;
const value = data[formField.name];
const isEmpty = value === undefined || value === null || (typeof value === 'string' && value === '');
if (isEmpty) errors[formField.name] = t('form.required', 'This field is required.');
}
return errors;
},
[t],
);
const applySetError = useCallback(
(error: JmapSetError): number | null => {
setGeneralError(friendlySetError(error));
const fieldToPage = new Map<string, number>();
sections.forEach((section, idx) => {
for (const rf of section.fields) {
if (!fieldToPage.has(rf.formField.name)) fieldToPage.set(rf.formField.name, idx);
}
});
const newErrors: Record<string, string> = {};
let earliest: number | null = null;
const record = (topLevel: string, msg: string) => {
if (!fieldToPage.has(topLevel)) return;
newErrors[topLevel] = msg;
const idx = fieldToPage.get(topLevel)!;
if (earliest === null || idx < earliest) earliest = idx;
};
if (error.properties) {
for (const prop of error.properties) {
const top = prop.split('/')[0];
record(top, error.description ?? t('form.invalidValue', 'Invalid value.'));
}
}
if (error.validationErrors) {
for (const ve of error.validationErrors) {
const top = ve.property?.split('/')[0] ?? '';
if (!top) continue;
const msg =
ve.type === 'Required'
? t('form.required', 'This field is required.')
: ve.type === 'MaxLength'
? t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: ve.required })
: ve.type === 'MinLength'
? t('form.minLengthIs', 'Minimum length is {{min}}.', { min: ve.required })
: ve.type === 'MaxValue'
? t('form.maxValueIs', 'Maximum value is {{max}}.', { max: ve.required })
: ve.type === 'MinValue'
? t('form.minValueIs', 'Minimum value is {{min}}.', { min: ve.required })
: t('form.invalidValue', 'Invalid value.');
record(top, msg);
}
}
if (Object.keys(newErrors).length > 0) setFieldErrors(newErrors);
return earliest;
},
[sections, t],
);
const canGoBack = currentPage > 0;
const isLastPage = currentPage === sections.length - 1;
const handleNext = useCallback(() => {
const current = sections[currentPage];
if (!current) return;
const errs = validateSectionRequired(current.fields, formData);
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
return;
}
setGeneralError(null);
setFieldErrors({});
setCurrentPage((p) => Math.min(p + 1, sections.length - 1));
}, [currentPage, sections, formData, validateSectionRequired, t]);
const handleBack = useCallback(() => {
setGeneralError(null);
setFieldErrors({});
setCurrentPage((p) => Math.max(p - 1, 0));
}, []);
const handleSubmit = useCallback(async () => {
if (!resolved) return;
const { obj } = resolved;
let firstInvalid: number | null = null;
const allErrors: Record<string, string> = {};
sections.forEach((section, idx) => {
const errs = validateSectionRequired(section.fields, formData);
for (const [k, v] of Object.entries(errs)) {
allErrors[k] = v;
if (firstInvalid === null) firstInvalid = idx;
}
});
if (firstInvalid !== null) {
setFieldErrors(allErrors);
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
setCurrentPage(firstInvalid);
return;
}
setSaving(true);
setGeneralError(null);
setFieldErrors({});
try {
const patch = calculateJmapPatch(originalData, formData);
const accountId = getAccountId(obj.objectName);
const responses = await jmapSet(obj.objectName, accountId, {
update: { singleton: patch },
});
const setResult = responses[responses.length - 1]?.[1] as unknown as JmapSetResponse;
if (setResult.updated && 'singleton' in setResult.updated) {
setSuccessExtras(setResult.updated.singleton ?? {});
} else if (setResult.notUpdated && setResult.notUpdated.singleton) {
const earliest = applySetError(setResult.notUpdated.singleton);
if (earliest !== null) setCurrentPage(earliest);
} else {
setGeneralError(t('bootstrap.noConfirm', 'The server did not confirm the update.'));
}
} catch (err) {
setGeneralError(
err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'),
);
} finally {
setSaving(false);
}
}, [resolved, sections, formData, originalData, validateSectionRequired, applySetError, t]);
const handleCopy = useCallback(
async (key: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
} catch {
toast({
title: t('bootstrap.copyFailed', 'Copy failed'),
description: t('bootstrap.clipboardBlocked', 'Your browser blocked clipboard access.'),
variant: 'destructive',
});
}
},
[t],
);
if (!schema || !resolved) {
return (
<WizardShell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{t('form.loadingSchema', 'Loading schema...')}
</div>
</WizardShell>
);
}
if (loading) {
return (
<WizardShell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{t('bootstrap.loadingSetup', 'Loading setup...')}
</div>
</WizardShell>
);
}
if (successExtras !== null) {
return (
<WizardShell>
<SuccessScreen
extras={successExtras}
fields={resolved.fields}
labelsByName={labelsByFieldName(resolved.form)}
onCopy={handleCopy}
copied={copied}
/>
</WizardShell>
);
}
if (sections.length === 0) {
return (
<WizardShell>
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
</div>
</WizardShell>
);
}
const current = sections[currentPage];
return (
<WizardShell>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to Stalwart')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
</p>
</div>
<ProgressIndicator total={sections.length} current={currentPage} />
{generalError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<p className="text-sm text-destructive whitespace-pre-line">{generalError}</p>
</div>
)}
<Card>
{current.title && (
<CardHeader>
<CardTitle className="text-base">{current.title}</CardTitle>
</CardHeader>
)}
<CardContent className={current.title ? '' : 'pt-6'}>
<div className="space-y-6">
<FormEditionContext.Provider value="oss">
{current.fields.map(({ formField, field }) => (
<FieldWidget
key={formField.name}
field={field}
formField={formField}
value={formData[formField.name]}
onChange={(v) => handleFieldChange(formField.name, v)}
readOnly={saving}
error={fieldErrors[formField.name]}
schema={schema}
/>
))}
</FormEditionContext.Provider>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={handleBack} disabled={!canGoBack || saving}>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('common.back', 'Back')}
</Button>
<div className="text-xs text-muted-foreground">
{t('bootstrap.stepOf', 'Step {{current}} of {{total}}', {
current: currentPage + 1,
total: sections.length,
})}
</div>
{isLastPage ? (
<Button type="button" onClick={handleSubmit} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
{t('bootstrap.finishSetup', 'Finish setup')}
</Button>
) : (
<Button type="button" onClick={handleNext} disabled={saving}>
{t('common.next', 'Next')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</div>
</WizardShell>
);
}
function WizardShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-content-background">
<header className="flex items-center px-6 py-4 border-b bg-background">
<DefaultLogo />
</header>
<main className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-3xl">{children}</div>
</main>
</div>
);
}
function ProgressIndicator({ total, current }: { total: number; current: number }) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2"
aria-label={t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: current + 1, total })}
>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={`h-2 w-2 rounded-full transition-colors ${
i < current ? 'bg-primary' : i === current ? 'bg-primary' : 'bg-muted-foreground/20'
}`}
/>
))}
</div>
);
}
function SuccessScreen({
extras,
fields,
labelsByName,
onCopy,
copied,
}: {
extras: Record<string, unknown>;
fields: Fields;
labelsByName: Map<string, string>;
onCopy: (key: string, value: string) => void;
copied: string | null;
}) {
const { t } = useTranslation();
const entries = Object.entries(extras).filter(([k, v]) => {
if (k === 'id' || k === 'blobId' || k === '@type') return false;
return typeof v === 'string' && v.length > 0;
}) as [string, string][];
const hasCredentials = entries.length > 0;
return (
<div className="space-y-6">
<div className="flex flex-col items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Check className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.complete', 'Setup complete')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{hasCredentials
? t(
'bootstrap.credentialsCreated',
'Your administrator account has been created. Write these down now: the password will not be shown again.',
)
: t('bootstrap.configuredSuccessfully', 'Stalwart has been configured successfully.')}
</p>
</div>
</div>
{hasCredentials && (
<Card>
<CardContent className="pt-6 space-y-4">
{entries.map(([key, value]) => {
const field = fields.properties[key];
const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key;
return (
<div key={key} className="space-y-1.5">
<div className="text-sm font-medium">{label}</div>
<div className="flex gap-2">
<code className="flex-1 rounded bg-muted p-2 text-sm break-all select-all">{value}</code>
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(key, value)}>
{copied === key ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
<div className="rounded-md border border-primary/20 bg-primary/5 p-4">
<p className="text-sm">
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
{t(
'bootstrap.nextStepBody',
'restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
)}
</p>
</div>
</div>
);
}
function labelsByFieldName(form: Form | null): Map<string, string> {
const map = new Map<string, string>();
if (!form) return map;
for (const section of form.sections) {
for (const ff of section.fields) map.set(ff.name, ff.label);
}
return map;
}
export default BootstrapWizard;
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface EnterpriseUpsellProps {
open: boolean;
onClose: () => void;
}
export function EnterpriseUpsell({ open, onClose }: EnterpriseUpsellProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('enterprise.trialTitle')}</DialogTitle>
<DialogDescription>{t('enterprise.trialDescription')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t('common.close')}
</Button>
<Button asChild>
<a href="https://license.stalw.art/trial" target="_blank" rel="noopener noreferrer">
{t('enterprise.trialButton')}
</a>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+240
View File
@@ -0,0 +1,240 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, List, Settings, Plus } from 'lucide-react';
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject } from '@/lib/schemaResolver';
import type { Schema } from '@/types/schema';
const MAX_RESULTS = 15;
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
link: 0,
form: 1,
field: 2,
};
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
const resolved = resolveObject(schema, viewName);
if (!resolved) return null;
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
}
function getActionInfo(
entryType: SearchIndexEntry['type'],
objectKind: 'singleton' | 'object' | null,
t: (key: string, fallback: string) => string,
): { label: string; Icon: typeof List } {
if (entryType === 'link') {
return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.list', 'List'), Icon: List };
}
return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
}
function getNavigationPath(
entryType: SearchIndexEntry['type'],
objectKind: 'singleton' | 'object' | null,
section: string,
viewName: string,
): string {
const encodedView = viewName;
if (entryType === 'link') {
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
}
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
}
function friendlyName(viewName: string): string {
const stripped = viewName.replace(/^x:/, '');
const parts = stripped.split('/');
return parts[parts.length - 1];
}
export function GlobalSearch() {
const { t } = useTranslation();
const navigate = useNavigate();
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
link: t('globalSearch.pages', 'Pages'),
form: t('globalSearch.formSections', 'Form Sections'),
field: t('globalSearch.fields', 'Fields'),
};
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const schema = useSchemaStore((s) => s.schema);
const searchIndex = useSchemaStore((s) => s.searchIndex);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(-1);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setDebouncedQuery(value), 300);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const results = useMemo(() => {
if (!debouncedQuery.trim() || !schema) return [];
const tokens = debouncedQuery
.toLowerCase()
.split(/\s+/)
.filter((s) => s.length > 0);
if (tokens.length === 0) return [];
const filtered = searchIndex.filter((entry) => {
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
for (const token of tokens) {
if (!haystack.includes(token)) return false;
}
const resolved = resolveObject(schema, entry.viewName);
if (!resolved) return false;
return hasObjectPermission(resolved.permissionPrefix, 'Get');
});
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
return filtered.slice(0, MAX_RESULTS);
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
const groups = useMemo(() => {
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
for (const entry of results) {
const arr = map.get(entry.type);
if (arr) arr.push(entry);
else map.set(entry.type, [entry]);
}
return map;
}, [results]);
const handleSelect = useCallback(
(entry: SearchIndexEntry) => {
if (!schema) return;
const objectKind = getObjectKind(schema, entry.viewName);
const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName);
setDropdownOpen(false);
setQuery('');
setDebouncedQuery('');
navigate(path);
},
[schema, navigate],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!dropdownOpen || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => (i + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i - 1 + results.length) % results.length);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
handleSelect(results[activeIndex]);
} else if (e.key === 'Escape') {
setDropdownOpen(false);
}
},
[dropdownOpen, results, activeIndex, handleSelect],
);
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
return (
<div className="flex flex-1 items-center justify-center px-4" ref={containerRef}>
<div className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
handleQueryChange(e.target.value);
setDropdownOpen(true);
}}
onFocus={() => {
if (query.trim()) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{showDropdown && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
{results.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t('globalSearch.noResults', 'No results found.')}
</div>
) : (
<div className="max-h-80 overflow-y-auto py-1">
{Array.from(groups.entries()).map(([type, entries]) => (
<div key={type}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
{entries.map((entry) => {
const flatIdx = results.indexOf(entry);
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
return (
<button
key={`${type}-${entry.viewName}-${flatIdx}`}
type="button"
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
flatIdx === activeIndex ? 'bg-accent' : ''
}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(entry);
}}
onMouseEnter={() => setActiveIndex(flatIdx)}
>
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
</button>
);
})}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getApiBaseUrl } from '@/services/api';
export function DefaultLogo() {
const { t } = useTranslation();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="95 84 500 90"
aria-label={t('logo.stalwartAlt', 'Stalwart Logo')}
className="h-7 w-auto max-w-[320px]"
>
<path
className="fill-current"
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z"
/>
<path
fill="#db2d54"
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z"
/>
</svg>
);
}
export default function Logo() {
const { t } = useTranslation();
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
async function fetchLogo() {
try {
const response = await fetch(`${getApiBaseUrl()}/logo`, {
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
const blob = await response.blob();
if (!controller.signal.aborted) {
const url = URL.createObjectURL(blob);
setLogoUrl(url);
}
} else {
if (!controller.signal.aborted) setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
}
}
fetchLogo();
return () => {
controller.abort();
};
}, []);
useEffect(() => {
return () => {
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (logoUrl && !failed) {
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
}
return <DefaultLogo />;
}
+135
View File
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useObjectList, useObjectLabel, useNoPermissionMessage } from '@/lib/objectOptions';
import type { Schema } from '@/types/schema';
interface ObjectPickerProps {
schema: Schema;
objectName: string;
value: string;
onChange: (id: string) => void;
onClear?: () => void;
placeholder?: string;
}
export function ObjectPicker({ schema, objectName, value, onChange, onClear, placeholder }: ObjectPickerProps) {
const { t } = useTranslation();
const list = useObjectList(objectName, schema);
const fromList = list.options.find((o) => o.id === value)?.label;
const { label: cheapLabel, loading: labelLoading } = useObjectLabel(
objectName,
fromList ? null : value || null,
schema,
);
const display = fromList ?? cheapLabel;
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const noPermissionMessage = useNoPermissionMessage(schema, objectName);
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) void list.ensureLoaded();
};
return (
<div className="flex items-center gap-2 flex-wrap">
{value && (
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm">
{labelLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : (display ?? value)}
{onClear && (
<button
type="button"
onClick={onClear}
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
aria-label={t('common.clear', 'Clear')}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
)}
{!value && placeholder && <span className="text-sm text-muted-foreground">{placeholder}</span>}
{noPermissionMessage ? (
<TooltipProvider>
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-not-allowed opacity-60"
aria-label={t('common.search', 'Search')}
aria-disabled="true"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTooltipOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{noPermissionMessage}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={t('common.search', 'Search')}
>
<Search className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-72" align="start">
<Command>
<CommandInput placeholder={t('common.searchPlaceholder', 'Search...')} />
<CommandList>
{list.loading && (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('common.loading', 'Loading...')}
</div>
)}
{!list.loading && <CommandEmpty>{t('common.noResultsDot', 'No results.')}</CommandEmpty>}
<CommandGroup>
{list.options.map((opt) => (
<CommandItem
key={opt.id}
value={opt.id}
keywords={[opt.label]}
onSelect={() => {
onChange(opt.id);
setOpen(false);
}}
>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -0,0 +1,175 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { X, Plus } from 'lucide-react';
function BufferedExprInput({
value,
onCommit,
...rest
}: { value: string; onCommit: (v: string) => void } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value'
>) {
const [local, setLocal] = useState(value);
useEffect(() => {
setLocal(value);
}, [value]);
const commit = () => {
if (local !== value) onCommit(local);
};
return (
<Input
{...rest}
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
/>
);
}
interface ExpressionEditorProps {
value: {
match: Record<string, { if: string; then: string }>;
else: string;
};
onChange: (value: { match: Record<string, { if: string; then: string }>; else: string }) => void;
readOnly?: boolean;
}
export function ExpressionEditor({ value, onChange, readOnly = false }: ExpressionEditorProps) {
const { t } = useTranslation();
const [nextIndex, setNextIndex] = useState(() => {
const keys = Object.keys(value.match).map(Number).filter(Number.isFinite);
return keys.length > 0 ? Math.max(...keys) + 1 : 0;
});
const matchEntries = Object.entries(value.match);
const handleConditionChange = (key: string, field: 'if' | 'then', fieldValue: string) => {
onChange({
...value,
match: {
...value.match,
[key]: { ...value.match[key], [field]: fieldValue },
},
});
};
const handleElseChange = (elseValue: string) => {
onChange({ ...value, else: elseValue });
};
const handleAdd = () => {
const key = String(nextIndex);
setNextIndex(nextIndex + 1);
onChange({
...value,
match: { ...value.match, [key]: { if: '', then: '' } },
});
};
const handleRemove = (key: string) => {
const { [key]: _removed, ...rest } = value.match;
void _removed;
onChange({ ...value, match: rest });
};
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
{matchEntries.map(([key, entry], index) => (
<div key={key}>
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.if', 'IF')}
</span>
<BufferedExprInput
value={entry.if}
onCommit={(v) => handleConditionChange(key, 'if', v)}
disabled={readOnly}
placeholder={t('expression.condition', 'Condition')}
className="flex-1"
/>
{!readOnly && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(key)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.then', 'THEN')}
</span>
<BufferedExprInput
value={entry.then}
onCommit={(v) => handleConditionChange(key, 'then', v)}
disabled={readOnly}
placeholder={t('expression.result', 'Result')}
className="flex-1"
/>
{!readOnly && <div className="w-8 shrink-0" />}
</div>
</div>
{index < matchEntries.length - 1 && <Separator className="my-3" />}
</div>
))}
{!readOnly && (
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAdd}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="h-3 w-3" />
{t('expression.addCondition', 'Add condition')}
</Button>
</div>
)}
{matchEntries.length > 0 && <Separator />}
<div className="flex items-center gap-2">
{matchEntries.length > 0 && (
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.else', 'ELSE')}
</span>
)}
<BufferedExprInput
value={value.else}
onCommit={handleElseChange}
disabled={readOnly}
placeholder={
matchEntries.length > 0 ? t('expression.defaultValue', 'Default value') : t('expression.value', 'Value')
}
className="flex-1"
/>
{!readOnly && matchEntries.length > 0 && <div className="w-8 shrink-0" />}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { createContext, useContext } from 'react';
import { useAccountStore } from '@/stores/accountStore';
export type EditionValue = 'enterprise' | 'community' | 'oss';
export const FormEditionContext = createContext<EditionValue | null>(null);
export function useEffectiveEdition(): EditionValue {
const override = useContext(FormEditionContext);
const storeEdition = useAccountStore((s) => s.edition);
return override ?? storeEdition;
}
+233
View File
@@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode';
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface OtpAuthValue {
otpUrl?: string | null;
otpCode?: string | null;
}
interface OtpAuthFieldProps {
value: unknown;
onChange: (value: unknown) => void;
readOnly: boolean;
}
const SECRET_MASK = '*****';
const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png';
function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
const base = totp.toString();
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}image=${encodeURIComponent(STALWART_IMAGE_URL)}`;
}
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'Stalwart',
label: 'account',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 }),
});
return { totp, url: buildOtpAuthUrl(totp) };
}
export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
const { t } = useTranslation();
const objValue = (value as OtpAuthValue | null | undefined) ?? {};
const isConfigured = objValue.otpUrl != null && objValue.otpUrl !== '';
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [setupCode, setSetupCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null);
useEffect(() => {
if (!setupUrl) return;
let cancelled = false;
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
.then((dataUrl) => {
if (!cancelled) setQrDataUrl(dataUrl);
})
.catch((err) => {
console.error('Failed to render QR code', err);
});
return () => {
cancelled = true;
};
}, [setupUrl]);
const startSetup = () => {
const { totp, url } = generateTotp();
setSetupTotp(totp);
setSetupUrl(url);
setSetupCode('');
setSetupError(null);
};
const confirmSetup = () => {
if (!setupTotp || !setupUrl) return;
if (!setupCode.trim()) {
setSetupError(t('otp.enterCodePrompt', 'Enter the code shown in your authenticator.'));
return;
}
const delta = setupTotp.validate({ token: setupCode.trim(), window: 1 });
if (delta === null) {
setSetupError(t('otp.codeIncorrect', 'That code is incorrect. Make sure your authenticator clock is in sync.'));
return;
}
setSetupError(null);
onChange({ otpUrl: setupUrl, otpCode: setupCode.trim() });
setSetupTotp(null);
setSetupUrl(null);
setSetupCode('');
};
const cancelSetup = () => {
setSetupTotp(null);
setSetupUrl(null);
setSetupCode('');
setSetupError(null);
};
const otpCodeValue = useMemo(
() => (typeof objValue.otpCode === 'string' && objValue.otpCode !== SECRET_MASK ? objValue.otpCode : ''),
[objValue.otpCode],
);
const handleCodeChange = (code: string) => {
onChange({ ...objValue, otpCode: code });
};
const handleDisable = () => {
onChange({ ...objValue, otpUrl: null });
};
if (readOnly) {
return (
<div className="text-sm text-muted-foreground">
{isConfigured
? t('otp.statusEnabled', 'Two-factor authentication is enabled.')
: t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</div>
);
}
if (!isConfigured && setupUrl) {
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">{t('otp.scanPrompt', 'Scan with your authenticator app')}</p>
<p className="text-xs text-muted-foreground">
{t(
'otp.scanDescription',
'Scan the QR code below with Google Authenticator, 1Password, Authy, or any other TOTP app, then enter the 6-digit code it shows to confirm setup.',
)}
</p>
</div>
<div className="flex justify-center">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt={t('otp.qrCodeAlt', 'TOTP QR code')}
width={220}
height={220}
className="rounded-md border bg-white p-2"
/>
) : (
<div className="flex h-[220px] w-[220px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelSetup}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" size="sm" onClick={confirmSetup}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('common.confirm', 'Confirm')}
</Button>
</div>
</div>
);
}
if (!isConfigured) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</span>
<Button type="button" variant="outline" size="sm" onClick={startSetup}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('otp.setUp', 'Set up TOTP')}
</Button>
</div>
);
}
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">{t('otp.statusEnabled', 'Two-factor authentication is enabled.')}</span>
</div>
<p className="text-xs text-muted-foreground">
{t(
'otp.currentCodePrompt',
'Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).',
)}
</p>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.currentCodeLabel', 'Current code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={otpCodeValue}
onChange={(e) => handleCodeChange(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
</div>
<div className="flex items-center justify-end">
<Button type="button" variant="destructive" size="sm" onClick={handleDisable} disabled={!otpCodeValue.trim()}>
<ShieldOff className="mr-2 h-4 w-4" />
{t('otp.disable', 'Disable')}
</Button>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import i18n from '@/i18n';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div role="alert" className="flex flex-col items-center justify-center gap-4 p-8">
<div className="text-destructive text-lg font-semibold">
{i18n.t('errorBoundary.title', 'Something went wrong')}
</div>
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')}
</p>
<Button variant="outline" onClick={this.handleReset}>
{i18n.t('errorBoundary.tryAgain', 'Try Again')}
</Button>
</div>
);
}
return this.props.children;
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect } from 'react';
import { useSchemaStore } from '@/stores/schemaStore';
import { useCacheStore } from '@/stores/cacheStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject } from '@/lib/schemaResolver';
import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { DashboardView } from '@/features/dashboard/components/DashboardView';
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
import { ActionPage } from '@/features/actions/ActionPage';
interface MainContentProps {
viewName?: string;
id?: string;
section?: string;
}
export function MainContent({ viewName, id, section }: MainContentProps) {
const schema = useSchemaStore((s) => s.schema);
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
useEffect(() => {
invalidateAllObjectLists();
}, [viewName, invalidateAllObjectLists]);
if (!viewName) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
);
}
if (viewName.startsWith('Dashboard/')) {
const dashboardId = viewName.slice('Dashboard/'.length);
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
}
if (viewName.startsWith('CustomComponent/')) {
const componentName = viewName.slice('CustomComponent/'.length);
if (componentName === 'Dashboard') {
const firstId = schema?.dashboards?.[0]?.id ?? 'overview';
return <DashboardView dashboardId={firstId} section={section ?? ''} />;
}
if (componentName === 'LiveDelivery') {
return <DeliveryTracePage />;
}
if (componentName === 'LiveTracing') {
return <LiveTracingPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName}
</div>
);
}
if (!schema) {
return <div className="flex items-center justify-center p-8 text-muted-foreground">Loading...</div>;
}
const resolved = resolveObject(schema, viewName);
if (!resolved) {
return <div className="flex items-center justify-center p-8 text-destructive">Unknown view: {viewName}</div>;
}
if (resolved.objectName === 'x:Action') {
return <ActionPage viewName={viewName} />;
}
if (resolved.objectType.type === 'singleton') {
return <DynamicForm viewName={viewName} objectId="singleton" />;
}
if (id === 'new') {
return <DynamicForm viewName={viewName} objectId={null} />;
}
if (id) {
if (resolved.objectName === 'x:Trace' && id !== 'new') {
return <TraceDetailView viewName={viewName} objectId={id} />;
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
if (!canUpdate) {
return <DynamicViewPage viewName={viewName} objectId={id} />;
}
return <DynamicForm viewName={viewName} objectId={id} />;
}
return <DynamicList viewName={viewName} />;
}
+19
View File
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const accessToken = useAuthStore((s) => s.accessToken);
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
const location = useLocation();
if (!accessToken && !bypassToken) {
const originalPath = location.pathname + location.search;
return <Navigate to="/login" replace state={{ from: originalPath }} />;
}
return <>{children}</>;
}
+354
View File
@@ -0,0 +1,354 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons;
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useUIStore } from '@/stores/uiStore';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import {
visibleLayouts,
findFirstVisibleLinkInLayout,
findFirstAccessibleLinkInLayout,
isLinkEnterprise,
isLinkVisible,
} from '@/lib/layout';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
function LucideIcon({ name, className }: { name: string; className?: string }) {
const formatted = name
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('');
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
if (!IconComp) return <LucideIcons.Circle className={className} />;
return <IconComp className={className} />;
}
function resolveViewPath(sectionName: string, viewName: string): string {
return `/${sectionName}/${viewName}`;
}
function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
const base = `/${sectionName}/${viewName}`;
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
if (viewName === 'CustomComponent/Dashboard') {
const dashBase = `/${sectionName}/Dashboard/`;
return currentPath.startsWith(dashBase);
}
return false;
}
function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
for (const item of items) {
if (item.type === 'link') {
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
} else if (item.type === 'container') {
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
}
}
return false;
}
function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
for (const item of items) {
if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) continue;
const enterprise = checkIsEnterprise(item.viewName);
if (enterprise && edition === 'oss') continue;
return true;
} else if (item.type === 'container') {
if (subtreeHasVisibleLink(item.items, edition)) return true;
}
}
return false;
}
function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return true;
const accountStore = useAccountStore.getState();
return isLinkVisible(
schema,
viewName,
accountStore.edition,
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
(perm: string) => accountStore.hasPermission(perm),
);
}
function checkIsEnterprise(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return false;
const edition = useAccountStore.getState().edition;
return isLinkEnterprise(schema, viewName, edition);
}
interface SidebarSubItemProps {
item: LayoutSubItem;
depth: number;
sectionName: string;
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
}
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) return null;
const path = resolveViewPath(sectionName, item.viewName);
const isActive = pathMatchesView(currentPath, sectionName, item.viewName);
const enterprise = checkIsEnterprise(item.viewName);
const isLocked = enterprise && edition === 'community';
const isHidden = enterprise && edition === 'oss';
if (isHidden) return null;
return (
<Button
variant="ghost"
className={cn(
'w-full justify-start gap-2 font-normal',
isActive && 'bg-accent text-accent-foreground',
depth > 0 && 'text-sm',
)}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
onClick={() => {
if (isLocked) {
onUpsell();
} else {
navigate(path);
}
}}
>
<span className="truncate">{item.name || 'Overview'}</span>
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</Button>
);
}
if (item.type === 'container') {
if (!subtreeHasVisibleLink(item.items, edition)) return null;
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start gap-2 font-normal text-sm"
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
>
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
<span className="truncate">{item.name}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{item.items.map((sub) => (
<SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name}
item={sub}
depth={depth + 1}
sectionName={sectionName}
currentPath={currentPath}
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
return null;
}
interface SidebarTopItemProps {
item: LayoutItem;
sectionName: string;
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
}
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
if ('link' in item) {
const { name, icon, viewName } = item.link;
if (!checkLinkVisible(viewName)) return null;
const path = resolveViewPath(sectionName, viewName);
const isActive = pathMatchesView(currentPath, sectionName, viewName);
const enterprise = checkIsEnterprise(viewName);
const isLocked = enterprise && edition === 'community';
const isHidden = enterprise && edition === 'oss';
if (isHidden) return null;
return (
<Button
variant="ghost"
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
onClick={() => {
if (isLocked) {
onUpsell();
} else {
navigate(path);
}
}}
>
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span>
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</Button>
);
}
if ('container' in item) {
const { name, icon, items } = item.container;
if (!subtreeHasVisibleLink(items, edition)) return null;
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span>
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{items.map((sub) => (
<SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name}
item={sub}
depth={1}
sectionName={sectionName}
currentPath={currentPath}
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
return null;
}
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const activeSection = useUIStore((s) => s.activeSection);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const [upsellOpen, setUpsellOpen] = useState(false);
const layouts = useMemo(
() =>
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
[schema, edition, hasObjectPermission, hasPermission],
);
useEffect(() => {
if (!schema) return;
if (layouts.length === 0) return;
if (!layouts.find((l) => l.name === activeSection)) {
setActiveSection(layouts[0].name);
}
}, [schema, layouts, activeSection, setActiveSection]);
if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
if (!layout) return null;
const handleSectionClick = (target: Layout) => {
setActiveSection(target.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const first =
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
if (first) navigate(`/${target.name}/${first}`);
};
return (
<aside className="fixed top-14 left-0 bottom-0 z-30 hidden w-64 flex-col border-r bg-background md:flex">
<ScrollArea className="flex-1 py-2">
<nav className="flex flex-col gap-0.5 px-2">
{layout.items.map((item) => (
<SidebarTopItem
key={'link' in item ? item.link.viewName : item.container.name}
item={item}
sectionName={layout.name}
currentPath={location.pathname}
navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
/>
))}
</nav>
</ScrollArea>
{layouts.length > 1 && (
<TooltipProvider>
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
{layouts.map((target) => {
const Icon = (LucideIcons as Record<string, unknown>)[
target.icon
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('')
] as LucideIcons.LucideIcon | undefined;
const isActive = target.name === activeSection;
return (
<Tooltip key={target.name}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={target.name}
aria-current={isActive ? 'page' : undefined}
onClick={() => handleSectionClick(target)}
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
>
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="top">{target.name}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
)}
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</aside>
);
}
+155
View File
@@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles } = LucideIcons;
import { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Logo from '@/components/common/Logo';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore';
import { useState } from 'react';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
function getIcon(name: string): LucideIcons.LucideIcon {
const formatted = name
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('');
return ((LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon) || LucideIcons.Circle;
}
export function TopBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useUIStore((s) => s.theme);
const toggleTheme = useUIStore((s) => s.toggleTheme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const accounts = useAuthStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false);
const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
: [];
return (
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b bg-background px-4">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
<Menu className="h-4 w-4" />
</Button>
<Link to="/" className="flex shrink-0 items-center">
<Logo />
</Link>
<GlobalSearch />
<div className="flex items-center gap-2">
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
<User className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{schema && navigableLayouts.length > 0 && (
<>
<DropdownMenuLabel>{t('sections', 'Sections')}</DropdownMenuLabel>
<DropdownMenuGroup>
{navigableLayouts.map((layout) => {
const Icon = getIcon(layout.icon);
return (
<DropdownMenuItem
key={layout.name}
onClick={() => {
setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const firstLink =
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
if (firstLink) {
navigate(`/${layout.name}/${firstLink}`);
}
}}
>
<Icon className="mr-2 h-4 w-4" />
{layout.name}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{Object.keys(accounts).length > 0 && (
<>
<DropdownMenuLabel>{t('accounts', 'Accounts')}</DropdownMenuLabel>
<DropdownMenuGroup>
{Object.entries(accounts).map(([id, info]) => (
<DropdownMenuItem key={id} onClick={() => switchAccount(id)}>
{id === activeAccountId && <Check className="mr-2 h-4 w-4" />}
<span className={id !== activeAccountId ? 'ml-6' : ''}>{info.name || id}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{edition !== 'enterprise' && (
<>
<DropdownMenuItem onClick={() => setUpsellOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
{t('tryEnterprise', 'Try Enterprise')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => {
logout();
navigate('/login');
}}
>
<LogOut className="mr-2 h-4 w-4" />
{t('logout', 'Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+35
View File
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+52
View File
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+49
View File
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { Tooltip as RechartsTooltip } from 'recharts';
import type { TooltipPayload } from 'recharts/types/state/tooltipSlice';
import { cn } from '@/lib/utils';
export const CHART_COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
] as const;
export function getChartColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
export function ChartContainer({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn('w-full', className)}>{children}</div>;
}
export function ChartTooltipContent({
active,
payload,
label,
formatter,
}: {
active?: boolean;
payload?: TooltipPayload;
label?: string;
formatter?: (value: number) => string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border bg-background p-2 shadow-md">
{label && <p className="mb-1 text-xs text-muted-foreground">{label}</p>}
{payload.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: entry.color ?? 'currentColor' }}
/>
<span className="text-muted-foreground">{entry.name ?? ''}</span>
<span className="ml-auto font-medium">
{formatter && typeof entry.value === 'number' ? formatter(entry.value) : String(entry.value ?? '')}
</span>
</div>
))}
</div>
);
}
export { RechartsTooltip };
+32
View File
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+142
View File
@@ -0,0 +1,142 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ChevronsUpDown, Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export interface ComboboxOption {
value: string;
label: string;
description?: string;
marker?: ReactNode;
keywords?: string[];
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
contentClassName?: string;
nullable?: boolean;
nullLabel?: string;
onFirstOpen?: () => void;
className?: string;
}
export function Combobox({
options,
value,
onValueChange,
placeholder = 'Select...',
searchPlaceholder = 'Search...',
emptyText = 'No matches.',
disabled,
contentClassName,
nullable,
nullLabel = 'None',
onFirstOpen,
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
const firstOpenRef = useRef(false);
useEffect(() => {
if (open && triggerRef.current) {
setTriggerWidth(triggerRef.current.offsetWidth);
}
}, [open]);
const handleOpenChange = (next: boolean) => {
if (next && !firstOpenRef.current) {
firstOpenRef.current = true;
onFirstOpen?.();
}
setOpen(next);
};
const selected = options.find((o) => o.value === value);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between font-normal', !selected && 'text-muted-foreground', className)}
>
<span className="flex items-center gap-2 truncate">
{selected?.marker}
{selected?.label ?? placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('p-0', contentClassName)}
align="start"
style={triggerWidth ? { width: triggerWidth } : undefined}
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{nullable && (
<CommandItem
value="__null__"
onSelect={() => {
onValueChange('');
setOpen(false);
}}
>
<X className="mr-2 h-4 w-4 opacity-50" />
<span className="text-muted-foreground">{nullLabel}</span>
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<Check className={cn('mr-2 h-4 w-4', option.value === value ? 'opacity-100' : 'opacity-0')} />
<span className="flex flex-1 items-center gap-2 truncate">
{option.marker}
<span className="flex flex-col truncate">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+131
View File
@@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+101
View File
@@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+186
View File
@@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+28
View File
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+23
View File
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+44
View File
@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+148
View File
@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-sm font-semibold', className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+26
View File
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+33
View File
@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+59
View File
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+27
View File
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };
+94
View File
@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastViewport = React.forwardRef<HTMLOListElement, React.HTMLAttributes<HTMLOListElement>>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
),
);
ToastViewport.displayName = 'ToastViewport';
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
success: 'border-green-500 bg-background text-foreground',
warning: 'border-yellow-500 bg-background text-foreground',
info: 'border-blue-500 bg-background text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
HTMLLIElement,
React.HTMLAttributes<HTMLLIElement> &
VariantProps<typeof toastVariants> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
>(({ className, variant, open, onOpenChange, ...props }, ref) => (
<li
ref={ref}
className={cn(toastVariants({ variant }), className)}
data-state={open ? 'open' : 'closed'}
{...props}
/>
));
Toast.displayName = 'Toast';
const ToastClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => (
<button
ref={ref}
aria-label="Close"
className={cn(
'absolute right-1 top-1 rounded-md p-1 text-foreground/60 transition-colors hover:text-foreground focus:outline-none focus:ring-1 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
type="button"
{...props}
>
<X className="h-4 w-4" />
</button>
),
);
ToastClose.displayName = 'ToastClose';
const ToastTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
),
);
ToastTitle.displayName = 'ToastTitle';
const ToastDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('text-sm opacity-90', className)} {...props} />,
);
ToastDescription.displayName = 'ToastDescription';
export { ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, toastVariants };
export type { VariantProps };
+30
View File
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport } from '@/components/ui/toast';
import { useToast } from '@/hooks/use-toast';
export function Toaster() {
const { toasts } = useToast();
const visibleToasts = toasts.filter((t) => t.open !== false);
return (
<ToastViewport>
{visibleToasts.map(function ({ id, title, description, variant, open, onOpenChange, ...props }) {
return (
<Toast key={id} variant={variant} open={open} onOpenChange={onOpenChange} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
<ToastClose onClick={() => onOpenChange?.(false)} />
</Toast>
);
})}
</ToastViewport>
);
}
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+543
View File
@@ -0,0 +1,543 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Check, X, HelpCircle, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { jmapMapToArray } from '@/lib/jmapUtils';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
import { formatSize, formatDuration } from '@/lib/durationFormat';
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant } from '@/types/schema';
export interface DynamicViewProps {
schema: Schema;
objectName: string;
viewName: string;
data: Record<string, unknown>;
visibleFields?: Set<string>;
}
export function DynamicView({ schema, objectName, viewName, data, visibleFields }: DynamicViewProps) {
const resolved = useMemo(() => {
const sch = resolveSchema(schema, objectName);
if (!sch) return null;
let fields: Fields | null = null;
let form: Form | null = null;
let variantSchemaName: string | undefined;
if (sch.type === 'single') {
fields = sch.fields;
form = resolveForm(schema, viewName, objectName, sch.schemaName);
} else {
const variantName = typeof data['@type'] === 'string' ? data['@type'] : undefined;
if (variantName) {
const variant = sch.variants.find((v) => v.name === variantName);
if (variant?.schemaName) {
variantSchemaName = variant.schemaName;
fields = schema.fields[variant.schemaName] ?? null;
form = resolveVariantForm(schema, viewName, objectName, variant.schemaName);
}
}
const parentForm = resolveForm(schema, viewName, objectName, objectName);
if (parentForm && form) {
form = { ...form, sections: [...parentForm.sections, ...form.sections] };
} else if (parentForm) {
form = parentForm;
}
}
return { sch, fields, form, variantSchemaName };
}, [schema, objectName, viewName, data]);
if (!resolved) return null;
const { fields, form, sch } = resolved;
const sections: { title?: string; items: { ff: FormField; field: Field }[] }[] = [];
if (form) {
for (const section of form.sections) {
const items: { ff: FormField; field: Field }[] = [];
for (const ff of section.fields) {
if (ff.name === '@type') continue;
if (visibleFields && !visibleFields.has(ff.name)) continue;
const field = fields?.properties[ff.name];
if (!field) continue;
if (isEmptyValue(data[ff.name])) continue;
items.push({ ff, field });
}
if (items.length > 0) {
sections.push({ title: section.title, items });
}
}
} else if (fields) {
const items: { ff: FormField; field: Field }[] = [];
for (const [name, field] of Object.entries(fields.properties)) {
if (visibleFields && !visibleFields.has(name)) continue;
if (isEmptyValue(data[name])) continue;
items.push({ ff: { name, label: name }, field });
}
if (items.length > 0) {
sections.push({ items });
}
}
const variantLabel =
sch.type === 'multiple' && typeof data['@type'] === 'string'
? sch.variants.find((v) => v.name === data['@type'])?.label
: undefined;
return (
<div className="space-y-4">
{variantLabel && (
<Badge variant="secondary" className="text-xs">
{variantLabel}
</Badge>
)}
{sections.map((section, si) => (
<Card key={si}>
{section.title && (
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
</CardHeader>
)}
<CardContent className={section.title ? 'pt-0' : ''}>
<dl className="space-y-2">
{section.items.map(({ ff, field }) => (
<ViewField key={ff.name} label={ff.label} field={field} value={data[ff.name]} schema={schema} />
))}
</dl>
</CardContent>
</Card>
))}
</div>
);
}
function ViewField({ label, field, value, schema }: { label: string; field: Field; value: unknown; schema: Schema }) {
const isBlock = isBlockType(field.type, value);
return (
<div className={isBlock ? 'space-y-1' : 'flex items-baseline gap-2'}>
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[140px]">
{label}
{field.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs prose prose-sm dark:prose-invert">
<ReactMarkdown>{field.description.replace(/\\n/g, '\n')}</ReactMarkdown>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</dt>
<dd className="text-sm min-w-0">
<ViewValue type={field.type} value={value} schema={schema} />
</dd>
</div>
);
}
function isBlockType(type: FieldType, value: unknown): boolean {
if (type.type === 'object' || type.type === 'objectList') return true;
if (type.type === 'map') return true;
if (type.type === 'string' && (type.format === 'text' || type.format === 'html')) return true;
if (type.type === 'set' && value && typeof value === 'object') {
return Object.keys(value as Record<string, unknown>).length > 5;
}
return false;
}
function ViewValue({ type, value, schema }: { type: FieldType; value: unknown; schema: Schema }) {
if (value === null || value === undefined) {
return <span className="italic text-muted-foreground">-</span>;
}
switch (type.type) {
case 'string':
return <StringValue value={value} format={type.format} />;
case 'number':
return <NumberValue value={value} format={type.format} />;
case 'utcDateTime':
return <DateTimeValue value={value} />;
case 'boolean':
return value === true ? <Check className="h-4 w-4 text-green-600" /> : <X className="h-4 w-4 text-red-500" />;
case 'enum':
return <EnumValue value={value} enumName={type.enumName} schema={schema} />;
case 'blobId':
return <span className="font-mono text-xs">{String(value)}</span>;
case 'objectId':
return <span>{String(value)}</span>;
case 'object':
return <ObjectValue value={value} objectName={type.objectName} schema={schema} />;
case 'objectList':
return <ObjectListValue value={value} objectName={type.objectName} schema={schema} />;
case 'set':
return <SetValue value={value} classType={type.class} schema={schema} />;
case 'map':
return <MapValue value={value} keyClass={type.keyClass} valueClass={type.valueClass} schema={schema} />;
default:
return <span>{JSON.stringify(value)}</span>;
}
}
function StringValue({ value, format }: { value: unknown; format: string }) {
const str = String(value);
if (!str) return <span className="italic text-muted-foreground">-</span>;
if (format === 'text' || format === 'html' || str.includes('\n')) {
return <pre className="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 text-xs font-mono">{str}</pre>;
}
if (format === 'secret' || format === 'secretText') {
return <span className="text-muted-foreground">*****</span>;
}
return <span className="break-all">{str}</span>;
}
function NumberValue({ value, format }: { value: unknown; format: string }) {
const num = typeof value === 'number' ? value : Number(value);
if (isNaN(num)) return <span>{String(value)}</span>;
switch (format) {
case 'size':
return <span>{formatSize(num)}</span>;
case 'duration':
return <span>{formatDuration(num)}</span>;
default:
return <span>{num.toLocaleString()}</span>;
}
}
function DateTimeValue({ value }: { value: unknown }) {
if (!value) return <span className="italic text-muted-foreground">-</span>;
const str = String(value);
const d = new Date(str);
const formatted = !isNaN(d.getTime())
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(d)
: str;
return <span>{formatted}</span>;
}
function EnumValue({ value, enumName, schema }: { value: unknown; enumName: string; schema: Schema }) {
const str = String(value);
const variants = schema.enums[enumName] ?? [];
const variant = variants.find((v: EnumVariant) => v.name === str);
if (variant) {
return (
<Badge variant="secondary" style={variant.color ? { backgroundColor: variant.color, color: '#fff' } : undefined}>
{variant.label}
</Badge>
);
}
return <span>{str}</span>;
}
function ObjectValue({
value,
objectName,
schema,
defaultOpen = false,
}: {
value: unknown;
objectName: string;
schema: Schema;
defaultOpen?: boolean;
}) {
if (!value || typeof value !== 'object') {
return <span className="italic text-muted-foreground">-</span>;
}
const data = value as Record<string, unknown>;
const sch = resolveSchema(schema, objectName);
let fields: Record<string, Field> | undefined;
let form: Form | null = null;
let variantLabel: string | undefined;
if (sch?.type === 'multiple') {
const variantName = typeof data['@type'] === 'string' ? data['@type'] : undefined;
if (variantName) {
const variant = sch.variants.find((v) => v.name === variantName);
variantLabel = variant?.label;
if (variant?.schemaName) {
fields = schema.fields[variant.schemaName]?.properties;
form = schema.forms[variant.schemaName] ?? null;
}
}
} else if (sch?.type === 'single') {
fields = schema.fields[sch.schemaName]?.properties;
form = schema.forms[sch.schemaName] ?? schema.forms[objectName] ?? null;
}
const entries = buildViewEntries(data, fields, form);
if (entries.length === 0 && !variantLabel) {
return null;
}
const triggerLabel = variantLabel ?? findDisplayValue(data) ?? objectName.replace(/^x:/, '');
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-sm hover:text-foreground text-muted-foreground transition-colors [&[data-state=open]>svg]:rotate-90"
>
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform duration-200" />
<span>{triggerLabel}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 mt-2 border-l-2 border-muted/40 pl-4 space-y-2">
{entries.map(({ key, label, field, value: val }) => {
if (!field) {
return (
<div key={key} className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground shrink-0 min-w-[120px]">{label}</span>
<span className="text-sm">{typeof val === 'object' ? JSON.stringify(val) : String(val ?? '-')}</span>
</div>
);
}
const block = isBlockType(field.type, val);
return (
<div key={key} className={block ? 'space-y-1' : 'flex items-baseline gap-2'}>
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[120px]">
{label}
{field.description && <FieldTooltip description={field.description} />}
</span>
<span className="text-sm min-w-0">
<ViewValue type={field.type} value={val} schema={schema} />
</span>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function ObjectListValue({ value, objectName, schema }: { value: unknown; objectName: string; schema: Schema }) {
const items = jmapMapToArray<Record<string, unknown>>(value);
if (items.length === 0) {
return <span className="italic text-muted-foreground">-</span>;
}
return (
<div className="space-y-2">
{items.map((item, i) => (
<ObjectValue key={i} value={item} objectName={objectName} schema={schema} />
))}
</div>
);
}
function SetValue({
value,
classType,
schema,
}: {
value: unknown;
classType: { type: string; enumName?: string };
schema: Schema;
}) {
if (!value || typeof value !== 'object') {
return <span className="italic text-muted-foreground">-</span>;
}
const keys = Object.keys(value as Record<string, unknown>);
if (keys.length === 0) {
return <span className="italic text-muted-foreground">-</span>;
}
const labels = keys.map((k) => {
if (classType.type === 'enum' && classType.enumName) {
const variants = schema.enums[classType.enumName] ?? [];
const v = variants.find((e: EnumVariant) => e.name === k);
return v?.label ?? k;
}
return k;
});
return (
<div className="flex flex-wrap gap-1">
{labels.map((label, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{label}
</Badge>
))}
</div>
);
}
function MapValue({
value,
keyClass,
valueClass,
schema,
}: {
value: unknown;
keyClass: { type: string; enumName?: string };
valueClass: { type: string; objectName?: string };
schema: Schema;
}) {
if (!value || typeof value !== 'object') {
return <span className="italic text-muted-foreground">-</span>;
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return <span className="italic text-muted-foreground">-</span>;
}
return (
<div className="space-y-2">
{entries.map(([k, v]) => {
let keyLabel = k;
if (keyClass.type === 'enum' && keyClass.enumName) {
const variants = schema.enums[keyClass.enumName] ?? [];
const variant = variants.find((e: EnumVariant) => e.name === k);
if (variant) keyLabel = variant.label;
}
if (valueClass.type === 'object' && valueClass.objectName) {
return (
<div key={k}>
<span className="text-sm font-medium">{keyLabel}</span>
<div className="ml-2">
<ObjectValue value={v} objectName={valueClass.objectName} schema={schema} />
</div>
</div>
);
}
return (
<div key={k} className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground shrink-0 min-w-[120px]">{keyLabel}</span>
<span className="text-sm">{typeof v === 'object' ? JSON.stringify(v) : String(v ?? '-')}</span>
</div>
);
})}
</div>
);
}
function FieldTooltip({ description }: { description: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs prose prose-sm dark:prose-invert">
<ReactMarkdown>{description.replace(/\\n/g, '\n')}</ReactMarkdown>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
interface ViewEntry {
key: string;
label: string;
field: Field | undefined;
value: unknown;
}
function buildViewEntries(
data: Record<string, unknown>,
fields?: Record<string, Field>,
form?: Form | null,
): ViewEntry[] {
const entries: ViewEntry[] = [];
const seen = new Set<string>();
if (form) {
for (const section of form.sections) {
for (const ff of section.fields) {
if (ff.name === '@type') continue;
if (isEmptyValue(data[ff.name])) continue;
seen.add(ff.name);
entries.push({
key: ff.name,
label: ff.label,
field: fields?.[ff.name],
value: data[ff.name],
});
}
}
}
for (const [key, val] of Object.entries(data)) {
if (key === '@type' || key === 'id' || seen.has(key)) continue;
if (isEmptyValue(val)) continue;
entries.push({
key,
label: key,
field: fields?.[key],
value: val,
});
}
return entries;
}
function isEmptyValue(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (value === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
if (typeof value === 'object' && value !== null) {
return Object.keys(value as Record<string, unknown>).length === 0;
}
return false;
}
function findDisplayValue(data: Record<string, unknown>): string | null {
for (const key of [
'name',
'domain',
'headerFrom',
'envelopeFrom',
'sourceIp',
'orgName',
'organizationName',
'email',
'from',
'to',
'subject',
'policyDomain',
]) {
const val = data[key];
if (typeof val === 'string' && val.length > 0) return val;
}
for (const val of Object.values(data)) {
if (typeof val === 'string' && val.length > 0 && val.length <= 80) return val;
}
return null;
}
+111
View File
@@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSchemaStore } from '@/stores/schemaStore';
import { resolveObject, resolveList } from '@/lib/schemaResolver';
import { jmapGet, getAccountId } from '@/services/jmap/client';
import { DynamicView } from './DynamicView';
interface DynamicViewPageProps {
viewName: string;
objectId: string;
}
export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const schema = useSchemaStore((s) => s.schema);
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!schema) return;
let cancelled = false;
async function load() {
try {
const resolved = resolveObject(schema!, viewName);
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
const accountId = getAccountId(resolved.objectName);
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
if (cancelled) return;
const getResult = responses.find(([name]) => name.endsWith('/get'));
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
const result = getResult[1] as { list?: Array<Record<string, unknown>> };
const item = result.list?.[0];
if (!item) throw new Error(t('view.objectNotFound', 'Object not found'));
setData(item);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : t('view.failedToLoad', 'Failed to load'));
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [schema, viewName, objectId, t]);
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data || !schema) {
return (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<div className="text-destructive p-4">{error ?? t('view.failedToLoad', 'Failed to load')}</div>
</div>
);
}
const resolved = resolveObject(schema, viewName);
if (!resolved) return null;
const list = resolveList(schema, viewName, resolved.objectName);
const labelProp = list?.labelProperty ?? list?.columns?.[0]?.name;
const displayName = labelProp && typeof data[labelProp] === 'string' ? (data[labelProp] as string) : undefined;
const singularName = list?.singularName ?? resolved.objectName.replace(/^x:/, '');
const title = displayName
? `${singularName.charAt(0).toUpperCase() + singularName.slice(1)}: ${displayName}`
: singularName.charAt(0).toUpperCase() + singularName.slice(1);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<h1 className="text-xl font-semibold">{title}</h1>
</div>
<DynamicView schema={schema} objectName={resolved.objectName} viewName={viewName} data={data} />
</div>
);
}