Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user