Add CalDAV outbox recovery UI
This commit is contained in:
@@ -191,6 +191,35 @@ export type CalendarSyncSourceSyncResponse = {
|
||||
|
||||
export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse;
|
||||
|
||||
export type CalendarOutboxActionAvailability = {
|
||||
allowed: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type CalendarOutboxOperation = {
|
||||
id: string;
|
||||
source_id: string;
|
||||
event_id?: string | null;
|
||||
operation_kind: "put" | "delete" | string;
|
||||
resource_href: string;
|
||||
status: string;
|
||||
attempt_count: number;
|
||||
max_attempts: number;
|
||||
available_at: string;
|
||||
last_attempt_at?: string | null;
|
||||
lease_expires_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
reconciled_at?: string | null;
|
||||
last_error?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
actions: Record<string, CalendarOutboxActionAvailability>;
|
||||
};
|
||||
|
||||
export type CalendarOutboxOperationListResponse = {
|
||||
operations: CalendarOutboxOperation[];
|
||||
};
|
||||
|
||||
export type CalendarCollectionCreatePayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
@@ -345,6 +374,28 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
|
||||
return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function listCalendarOutbox(
|
||||
settings: ApiSettings,
|
||||
params: { source_id?: string; status?: string; limit?: number } = {},
|
||||
): Promise<CalendarOutboxOperationListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.source_id) search.set("source_id", params.source_id);
|
||||
if (params.status) search.set("status", params.status);
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarOutboxOperationListResponse>(settings, `/api/v1/calendar/caldav/outbox${suffix}`);
|
||||
}
|
||||
|
||||
export function recoverCalendarOutboxOperation(
|
||||
settings: ApiSettings,
|
||||
operationId: string,
|
||||
action: "retry" | "reconcile" | "discard",
|
||||
): Promise<CalendarOutboxOperation> {
|
||||
return apiFetch<CalendarOutboxOperation>(settings, `/api/v1/calendar/caldav/outbox/${operationId}/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { RefreshCw, Trash2 } from "lucide-react";
|
||||
import { ListChecks, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ColorPickerField,
|
||||
@@ -101,6 +101,7 @@ export function CalendarCollectionDialog({
|
||||
onSave,
|
||||
onRequestDelete,
|
||||
onSync,
|
||||
onOpenOutbox,
|
||||
onDiscover
|
||||
|
||||
|
||||
@@ -116,7 +117,7 @@ export function CalendarCollectionDialog({
|
||||
|
||||
|
||||
|
||||
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onOpenOutbox: (source: CalendarSyncSource) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
const calendar = state.kind === "edit" ? state.calendar : null;
|
||||
const isEdit = Boolean(calendar);
|
||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
|
||||
@@ -559,6 +560,11 @@ export function CalendarCollectionDialog({
|
||||
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
|
||||
i18n:govoplan-calendar.full_sync.21b89c76
|
||||
</Button>
|
||||
{source.source_kind === "caldav" && canManageSources && (
|
||||
<Button type="button" onClick={() => onOpenOutbox(source)} disabled={saving || syncing}>
|
||||
<ListChecks size={16} /> i18n:govoplan-calendar.outbound_changes.7038a839
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
type ApiSettings,
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listCalendarOutbox,
|
||||
recoverCalendarOutboxOperation,
|
||||
type CalendarCollection,
|
||||
type CalendarOutboxOperation,
|
||||
type CalendarSyncSource,
|
||||
} from "../../api/calendar";
|
||||
import { dateTimeLabel, errorText } from "./calendarViewModel";
|
||||
|
||||
type OutboxFilter = "unresolved" | "all";
|
||||
type RecoveryAction = "retry" | "reconcile" | "discard";
|
||||
|
||||
const RESOLVED_STATUSES = new Set(["succeeded", "superseded", "cancelled"]);
|
||||
|
||||
export function CalendarOutboxDialog({
|
||||
settings,
|
||||
calendar,
|
||||
source,
|
||||
onClose,
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
calendar: CalendarCollection;
|
||||
source: CalendarSyncSource;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [operations, setOperations] = useState<CalendarOutboxOperation[]>([]);
|
||||
const [filter, setFilter] = useState<OutboxFilter>("unresolved");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyOperationId, setBusyOperationId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [discardOperation, setDiscardOperation] = useState<CalendarOutboxOperation | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCalendarOutbox(settings, {
|
||||
source_id: source.id,
|
||||
limit: 100,
|
||||
});
|
||||
setOperations(response.operations);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings, source.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const visibleOperations = useMemo(
|
||||
() => filter === "all"
|
||||
? operations
|
||||
: operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)),
|
||||
[filter, operations],
|
||||
);
|
||||
const unresolvedCount = operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)).length;
|
||||
const conflictCount = operations.filter((operation) => ["conflict", "dead"].includes(operation.status)).length;
|
||||
|
||||
async function recover(operation: CalendarOutboxOperation, action: RecoveryAction) {
|
||||
setBusyOperationId(operation.id);
|
||||
setError("");
|
||||
try {
|
||||
await recoverCalendarOutboxOperation(settings, operation.id, action);
|
||||
setDiscardOperation(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusyOperationId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
title="i18n:govoplan-calendar.outbound_changes.7038a839"
|
||||
className="calendar-outbox-dialog"
|
||||
closeDisabled={Boolean(busyOperationId)}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)}>
|
||||
<RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}>
|
||||
i18n:govoplan-core.close.bbfa773e
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="calendar-outbox-body">
|
||||
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="calendar-outbox-toolbar">
|
||||
<SegmentedControl<OutboxFilter>
|
||||
value={filter}
|
||||
ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40"
|
||||
onChange={setFilter}
|
||||
options={[
|
||||
{ id: "unresolved", label: "i18n:govoplan-calendar.unresolved.11c41de4" },
|
||||
{ id: "all", label: "i18n:govoplan-calendar.all_history.8829c44e" },
|
||||
]}
|
||||
/>
|
||||
<dl className="calendar-outbox-summary">
|
||||
<div><dt>i18n:govoplan-calendar.unresolved.11c41de4</dt><dd>{unresolvedCount}</dd></div>
|
||||
<div><dt>i18n:govoplan-calendar.conflicts_dead.31252c3a</dt><dd>{conflictCount}</dd></div>
|
||||
<div><dt>i18n:govoplan-calendar.shown.498e85a1</dt><dd>{visibleOperations.length}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
{loading && !operations.length
|
||||
? <p className="calendar-form-note">i18n:govoplan-calendar.loading_outbound_changes.3fc59656</p>
|
||||
: visibleOperations.length
|
||||
? (
|
||||
<ol className="calendar-outbox-list">
|
||||
{visibleOperations.map((operation) => (
|
||||
<li key={operation.id} className="calendar-outbox-item">
|
||||
<div className="calendar-outbox-item-heading">
|
||||
<div>
|
||||
<StatusBadge status={operation.status} />
|
||||
<strong>{operation.operation_kind.toUpperCase()}</strong>
|
||||
</div>
|
||||
<time dateTime={operation.updated_at}>{dateTimeLabel(new Date(operation.updated_at))}</time>
|
||||
</div>
|
||||
<code title={operation.resource_href}>{operation.resource_href}</code>
|
||||
<dl className="calendar-outbox-item-facts">
|
||||
<div><dt>i18n:govoplan-calendar.attempts.448fa12e</dt><dd>{operation.attempt_count}/{operation.max_attempts}</dd></div>
|
||||
<div><dt>i18n:govoplan-calendar.available.17bc146b</dt><dd>{dateTimeLabel(new Date(operation.available_at))}</dd></div>
|
||||
</dl>
|
||||
{operation.last_error && <p className="calendar-outbox-error">{operation.last_error}</p>}
|
||||
<RecoveryActions
|
||||
operation={operation}
|
||||
busy={busyOperationId === operation.id}
|
||||
onRecover={(action) => action === "discard"
|
||||
? setDiscardOperation(operation)
|
||||
: void recover(operation, action)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: <p className="calendar-form-note">i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64</p>}
|
||||
{operations.length === 100 && (
|
||||
<p className="calendar-form-note">i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(discardOperation)}
|
||||
title="i18n:govoplan-calendar.discard_local_desired_state.952f3be1"
|
||||
message="i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d"
|
||||
confirmLabel="i18n:govoplan-calendar.discard_change.85474ec4"
|
||||
tone="danger"
|
||||
busy={Boolean(busyOperationId)}
|
||||
onCancel={() => setDiscardOperation(null)}
|
||||
onConfirm={() => discardOperation && void recover(discardOperation, "discard")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryActions({
|
||||
operation,
|
||||
busy,
|
||||
onRecover,
|
||||
}: {
|
||||
operation: CalendarOutboxOperation;
|
||||
busy: boolean;
|
||||
onRecover: (action: RecoveryAction) => void;
|
||||
}) {
|
||||
const available = (["retry", "reconcile", "discard"] as const).filter(
|
||||
(action) => operation.actions[action]?.allowed,
|
||||
);
|
||||
const unavailableReason = Object.values(operation.actions).find((action) => action.reason)?.reason;
|
||||
return (
|
||||
<div className="calendar-outbox-recovery">
|
||||
{available.length > 0 && (
|
||||
<div className="calendar-outbox-actions">
|
||||
{available.includes("retry") && (
|
||||
<Button type="button" onClick={() => onRecover("retry")} disabled={busy}>
|
||||
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
|
||||
</Button>
|
||||
)}
|
||||
{available.includes("reconcile") && (
|
||||
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy}>
|
||||
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
|
||||
</Button>
|
||||
)}
|
||||
{available.includes("discard") && (
|
||||
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy}>
|
||||
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!available.length && unavailableReason && <p className="calendar-form-note">{unavailableReason}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
type CalendarDeleteDialogState,
|
||||
} from "./CalendarCollectionDialogs";
|
||||
import { CalendarEventDialog } from "./CalendarEventDialog";
|
||||
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
|
||||
import {
|
||||
CalendarTimeGrid,
|
||||
CalendarWeekRows,
|
||||
@@ -132,6 +133,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
|
||||
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
|
||||
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
|
||||
const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null);
|
||||
const [syncingSourceId, setSyncingSourceId] = useState("");
|
||||
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
|
||||
const [draggingEventId, setDraggingEventId] = useState("");
|
||||
@@ -1005,8 +1007,21 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
onSave={handleCalendarSave}
|
||||
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
|
||||
onSync={handleSyncSource}
|
||||
onOpenOutbox={(source) => {
|
||||
if (calendarDialog.kind === "edit") {
|
||||
setOutboxDialog({ calendar: calendarDialog.calendar, source });
|
||||
}
|
||||
}}
|
||||
onDiscover={handleCalDavDiscovery} />
|
||||
|
||||
}
|
||||
{outboxDialog &&
|
||||
<CalendarOutboxDialog
|
||||
settings={settings}
|
||||
calendar={outboxDialog.calendar}
|
||||
source={outboxDialog.source}
|
||||
onClose={() => setOutboxDialog(null)} />
|
||||
|
||||
}
|
||||
{calendarDeleteDialog &&
|
||||
<CalendarCollectionDeleteDialog
|
||||
|
||||
@@ -193,6 +193,23 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.week.f82be68a": "Week",
|
||||
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
||||
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
||||
"i18n:govoplan-calendar.all_history.8829c44e": "All history",
|
||||
"i18n:govoplan-calendar.attempts.448fa12e": "Attempts",
|
||||
"i18n:govoplan-calendar.available.17bc146b": "Available",
|
||||
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Conflicts / dead",
|
||||
"i18n:govoplan-calendar.discard.23a76911": "Discard",
|
||||
"i18n:govoplan-calendar.discard_change.85474ec4": "Discard change",
|
||||
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Discard local desired state?",
|
||||
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Discarding cancels this local outbound change and its unresolved predecessors. The next full sync accepts the remote state, so local changes may be lost.",
|
||||
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Loading outbound changes...",
|
||||
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "No outbound changes match this filter.",
|
||||
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Only the 100 most recent outbound changes are shown.",
|
||||
"i18n:govoplan-calendar.outbound_changes.7038a839": "Outbound changes",
|
||||
"i18n:govoplan-calendar.outbox_filter.8305af40": "Outbound change filter",
|
||||
"i18n:govoplan-calendar.reconcile.6c595f64": "Reconcile",
|
||||
"i18n:govoplan-calendar.retry.3fda8f1c": "Retry",
|
||||
"i18n:govoplan-calendar.shown.498e85a1": "Shown",
|
||||
"i18n:govoplan-calendar.unresolved.11c41de4": "Unresolved",
|
||||
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
||||
},
|
||||
"de": {
|
||||
@@ -387,6 +404,23 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.week.f82be68a": "Woche",
|
||||
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
||||
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
||||
"i18n:govoplan-calendar.all_history.8829c44e": "Gesamter Verlauf",
|
||||
"i18n:govoplan-calendar.attempts.448fa12e": "Versuche",
|
||||
"i18n:govoplan-calendar.available.17bc146b": "Verfügbar",
|
||||
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Konflikte / endgültig fehlgeschlagen",
|
||||
"i18n:govoplan-calendar.discard.23a76911": "Verwerfen",
|
||||
"i18n:govoplan-calendar.discard_change.85474ec4": "Änderung verwerfen",
|
||||
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Lokalen Sollzustand verwerfen?",
|
||||
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Das Verwerfen bricht diese lokale ausgehende Änderung und ihre ungelösten Vorgänger ab. Die nächste vollständige Synchronisierung übernimmt den entfernten Stand; lokale Änderungen können verloren gehen.",
|
||||
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Ausgehende Änderungen werden geladen...",
|
||||
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "Keine ausgehenden Änderungen entsprechen diesem Filter.",
|
||||
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Es werden nur die 100 neuesten ausgehenden Änderungen angezeigt.",
|
||||
"i18n:govoplan-calendar.outbound_changes.7038a839": "Ausgehende Änderungen",
|
||||
"i18n:govoplan-calendar.outbox_filter.8305af40": "Filter für ausgehende Änderungen",
|
||||
"i18n:govoplan-calendar.reconcile.6c595f64": "Abgleichen",
|
||||
"i18n:govoplan-calendar.retry.3fda8f1c": "Erneut versuchen",
|
||||
"i18n:govoplan-calendar.shown.498e85a1": "Angezeigt",
|
||||
"i18n:govoplan-calendar.unresolved.11c41de4": "Ungelöst",
|
||||
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1099,6 +1099,146 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-outbox-dialog {
|
||||
width: min(860px, 100%);
|
||||
}
|
||||
|
||||
.calendar-outbox-dialog .dialog-body {
|
||||
min-height: 360px;
|
||||
max-height: min(70vh, 720px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-outbox-body {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-outbox-calendar-name {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-outbox-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-outbox-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-outbox-summary div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.calendar-outbox-summary dt {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-outbox-summary dd {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calendar-outbox-list {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0 4px 0 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar-outbox-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 11px 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.calendar-outbox-item-heading,
|
||||
.calendar-outbox-item-heading > div,
|
||||
.calendar-outbox-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-outbox-item-heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calendar-outbox-item-heading time {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-outbox-item code {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-outbox-item-facts {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-outbox-item-facts div {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.calendar-outbox-item-facts dt {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-outbox-item-facts dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-outbox-error {
|
||||
margin: 0;
|
||||
padding: 8px 9px;
|
||||
border-left: 3px solid var(--red);
|
||||
background: var(--calendar-danger-bg);
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-outbox-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-form-error {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
|
||||
Reference in New Issue
Block a user