fix: show structured page placeholders while loading
This commit is contained in:
@@ -14,8 +14,8 @@ import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
|||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { FormModal } from "@/components/shared/FormModal";
|
import { FormModal } from "@/components/shared/FormModal";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||||
import { TimestampChip } from "@/components/shared/TimestampChip";
|
import { TimestampChip } from "@/components/shared/TimestampChip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -193,7 +193,7 @@ export function ConclusionBrowser() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{(isLoading || (activeSearch && searchLoading)) && <PageLoader />}
|
{(isLoading || (activeSearch && searchLoading)) && <ConclusionsSkeleton />}
|
||||||
|
|
||||||
{!isLoading && !searchLoading && displayedConclusions.length === 0 && (
|
{!isLoading && !searchLoading && displayedConclusions.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -315,6 +315,32 @@ export function ConclusionBrowser() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConclusionsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-hidden="true">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-xl p-5"
|
||||||
|
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-3 w-full rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[94%] rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[76%] rounded" />
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mt-4 pt-3"
|
||||||
|
style={{ borderTop: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-3 w-20 rounded" />
|
||||||
|
<Skeleton className="h-3 w-16 rounded" />
|
||||||
|
<Skeleton className="ml-auto h-6 w-28 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CreateConclusionModal({
|
function CreateConclusionModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
|||||||
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
@@ -182,7 +182,7 @@ export function Dashboard() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <DashboardSkeleton />}
|
||||||
|
|
||||||
{!isLoading && workspaces.length > 0 && (
|
{!isLoading && workspaces.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -265,3 +265,64 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" aria-hidden="true">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-xl p-4 theme-card">
|
||||||
|
<Skeleton accent={index === 0} className="h-8 w-16 rounded-lg" />
|
||||||
|
<Skeleton className="mt-3 h-3 w-20 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl theme-card overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-3"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<Skeleton accent className="h-4 w-4 rounded" />
|
||||||
|
<Skeleton className="h-4 w-28 rounded" />
|
||||||
|
<Skeleton className="ml-1 h-3 w-32 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "var(--bg-3)" }}>
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<th key={index} className="px-4 py-2 text-left">
|
||||||
|
<Skeleton className="h-3 w-14 rounded" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Skeleton accent className="h-3 w-28 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Skeleton className="h-3 w-20 rounded" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{Array.from({ length: 4 }).map((__, cellIndex) => (
|
||||||
|
<td key={cellIndex} className="px-4 py-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Skeleton className="h-3 w-8 rounded" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { JsonViewer } from "@/components/shared/JsonViewer";
|
|||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
|
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
|
||||||
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
|
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
|
||||||
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Textarea } from "@/components/ui/input";
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -143,7 +144,7 @@ export function PeerDetail() {
|
|||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <PeerDetailSkeleton />}
|
||||||
|
|
||||||
{!isLoading && peer && (
|
{!isLoading && peer && (
|
||||||
<>
|
<>
|
||||||
@@ -423,3 +424,45 @@ export function PeerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PeerDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" aria-hidden="true">
|
||||||
|
<div className="rounded-xl p-5 theme-card">
|
||||||
|
<Skeleton className="h-4 w-36 rounded" />
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Skeleton className="h-10 flex-1 rounded-lg" />
|
||||||
|
<Skeleton accent className="h-10 w-24 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-xl p-5 theme-card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-4 w-28 rounded" />
|
||||||
|
<Skeleton className="h-8 w-16 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-full rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[92%] rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[68%] rounded" />
|
||||||
|
<Skeleton className="mt-4 h-24 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl p-5 theme-card">
|
||||||
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
<Skeleton className="mt-4 h-3 w-full rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[95%] rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[76%] rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl theme-card overflow-hidden">
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<Skeleton className="h-4 w-20 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { components } from "@/api/schema.d.ts";
|
|||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||||
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
@@ -178,7 +178,7 @@ export function PeerList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <PeerListSkeleton />}
|
||||||
|
|
||||||
{!isLoading && peers.length === 0 && (
|
{!isLoading && peers.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -329,3 +329,40 @@ export function PeerList() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PeerListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-xl px-5 py-4"
|
||||||
|
style={{
|
||||||
|
background: COLOR.cardBaseBg,
|
||||||
|
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton accent className="h-4 w-40 rounded" />
|
||||||
|
<Skeleton className="h-4 w-4 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<Skeleton className="h-5 w-14 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-12 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<Skeleton className="h-3 w-28 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useSessions } from "@/api/queries";
|
|||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||||
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
@@ -105,7 +105,7 @@ export function SessionList() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <SessionListSkeleton />}
|
||||||
|
|
||||||
{!isLoading && sessions.length === 0 && (
|
{!isLoading && sessions.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -204,3 +204,40 @@ export function SessionList() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-xl px-5 py-4"
|
||||||
|
style={{
|
||||||
|
background: COLOR.cardBaseBg,
|
||||||
|
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton accent className="h-4 w-44 rounded" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{index % 2 === 0 && <Skeleton className="h-4 w-12 rounded-full" />}
|
||||||
|
<Skeleton className="h-4 w-4 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<Skeleton className="h-3 w-28 rounded" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
15
packages/web/src/components/shared/Skeleton.tsx
Normal file
15
packages/web/src/components/shared/Skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
accent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ accent = false, className, ...props }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("theme-skeleton rounded-md", accent && "theme-skeleton--accent", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
|
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
@@ -79,7 +79,6 @@ export function WebhookManager({ workspaceId }: Props) {
|
|||||||
|
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
|
||||||
|
|
||||||
{/* Add webhook form */}
|
{/* Add webhook form */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -133,73 +132,73 @@ export function WebhookManager({ workspaceId }: Props) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Webhook list */}
|
{/* Webhook list */}
|
||||||
{!isLoading && (
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 8 }}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ delay: 0.1 }}
|
||||||
transition={{ delay: 0.1 }}
|
className="rounded-xl theme-card overflow-hidden"
|
||||||
className="rounded-xl theme-card overflow-hidden"
|
>
|
||||||
>
|
{isLoading ? (
|
||||||
{list.length === 0 ? (
|
<WebhookListSkeleton />
|
||||||
<div className="p-8 text-center">
|
) : list.length === 0 ? (
|
||||||
<Webhook
|
<div className="p-8 text-center">
|
||||||
className="w-8 h-8 mx-auto mb-2 opacity-20"
|
<Webhook
|
||||||
style={{ color: "var(--text-3)" }}
|
className="w-8 h-8 mx-auto mb-2 opacity-20"
|
||||||
strokeWidth={1.5}
|
style={{ color: "var(--text-3)" }}
|
||||||
/>
|
strokeWidth={1.5}
|
||||||
<Muted>No webhook endpoints yet.</Muted>
|
/>
|
||||||
</div>
|
<Muted>No webhook endpoints yet.</Muted>
|
||||||
) : (
|
</div>
|
||||||
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
|
) : (
|
||||||
{list.map((wh, i) => (
|
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
|
||||||
<motion.div
|
{list.map((wh, i) => (
|
||||||
key={(wh as { id: string }).id}
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
key={(wh as { id: string }).id}
|
||||||
animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }}
|
||||||
transition={{ delay: i * 0.04 }}
|
animate={{ opacity: 1 }}
|
||||||
className="flex items-center justify-between px-5 py-3 gap-4"
|
transition={{ delay: i * 0.04 }}
|
||||||
>
|
className="flex items-center justify-between px-5 py-3 gap-4"
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="min-w-0 flex-1">
|
||||||
<span
|
<div className="flex items-center gap-1.5">
|
||||||
className="text-xs font-mono truncate"
|
<span
|
||||||
style={{ color: "var(--accent-text)" }}
|
className="text-xs font-mono truncate"
|
||||||
>
|
style={{ color: "var(--accent-text)" }}
|
||||||
{mask((wh as { url: string }).url)}
|
>
|
||||||
</span>
|
{mask((wh as { url: string }).url)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void open((wh as { url: string }).url)}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
color: "var(--text-4)",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
|
||||||
{mask((wh as { id: string }).id)}
|
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void open((wh as { url: string }).url)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-4)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
variant="ghost"
|
{mask((wh as { id: string }).id)}
|
||||||
size="icon"
|
</span>
|
||||||
onClick={() => setDeleteTarget((wh as { id: string }).id)}
|
</div>
|
||||||
aria-label="Delete webhook"
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
size="icon"
|
||||||
</Button>
|
onClick={() => setDeleteTarget((wh as { id: string }).id)}
|
||||||
</motion.div>
|
aria-label="Delete webhook"
|
||||||
))}
|
>
|
||||||
</div>
|
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
)}
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -217,3 +216,19 @@ export function WebhookManager({ workspaceId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WebhookListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="divide-y" style={{ borderColor: "var(--border)" }} aria-hidden="true">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between px-5 py-3 gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Skeleton accent className="h-3 w-52 rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-32 rounded" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } fr
|
|||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
|
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
||||||
@@ -107,7 +107,7 @@ export function WorkspaceDetail() {
|
|||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <WorkspaceDetailSkeleton />}
|
||||||
|
|
||||||
{!isLoading && workspace && (
|
{!isLoading && workspace && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -342,3 +342,42 @@ export function WorkspaceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" aria-hidden="true">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-xl p-5 theme-card">
|
||||||
|
<Skeleton accent className="h-5 w-5 rounded" />
|
||||||
|
<Skeleton className="mt-4 h-4 w-24 rounded" />
|
||||||
|
<Skeleton className="mt-3 h-3 w-32 rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-24 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl p-5 theme-card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Skeleton className="h-4 w-28 rounded" />
|
||||||
|
<Skeleton className="h-4 w-20 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<Skeleton accent className="h-8 w-12 rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl p-5 theme-card">
|
||||||
|
<Skeleton className="h-4 w-20 rounded" />
|
||||||
|
<Skeleton className="mt-4 h-3 w-full rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[92%] rounded" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-[64%] rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useWorkspaces } from "@/api/queries";
|
|||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||||
import { MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
import { MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
@@ -94,7 +94,7 @@ export function WorkspaceList() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <WorkspaceListSkeleton />}
|
||||||
|
|
||||||
{!isLoading && workspaces.length === 0 && (
|
{!isLoading && workspaces.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -156,3 +156,36 @@ export function WorkspaceList() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-xl px-5 py-4"
|
||||||
|
style={{
|
||||||
|
background: COLOR.cardBaseBg,
|
||||||
|
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton accent className="h-4 w-40 rounded" />
|
||||||
|
<Skeleton className="h-4 w-4 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<Skeleton className="h-3 w-32 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
--accent-dim: rgba(99, 102, 241, 0.15);
|
--accent-dim: rgba(99, 102, 241, 0.15);
|
||||||
--accent-border: rgba(99, 102, 241, 0.35);
|
--accent-border: rgba(99, 102, 241, 0.35);
|
||||||
--accent-text: #a5b4fc;
|
--accent-text: #a5b4fc;
|
||||||
|
--skeleton-base: rgba(255, 255, 255, 0.06);
|
||||||
|
--skeleton-highlight: rgba(255, 255, 255, 0.12);
|
||||||
|
--skeleton-accent-base: rgba(99, 102, 241, 0.12);
|
||||||
|
--skeleton-accent-highlight: rgba(99, 102, 241, 0.2);
|
||||||
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
|
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
|
||||||
--grid-line: rgba(99, 102, 241, 0.03);
|
--grid-line: rgba(99, 102, 241, 0.03);
|
||||||
--glow: rgba(79, 70, 229, 0.08);
|
--glow: rgba(79, 70, 229, 0.08);
|
||||||
@@ -64,6 +68,10 @@
|
|||||||
--accent-dim: rgba(79, 70, 229, 0.08);
|
--accent-dim: rgba(79, 70, 229, 0.08);
|
||||||
--accent-border: rgba(79, 70, 229, 0.25);
|
--accent-border: rgba(79, 70, 229, 0.25);
|
||||||
--accent-text: #4f46e5;
|
--accent-text: #4f46e5;
|
||||||
|
--skeleton-base: rgba(15, 23, 42, 0.06);
|
||||||
|
--skeleton-highlight: rgba(15, 23, 42, 0.1);
|
||||||
|
--skeleton-accent-base: rgba(79, 70, 229, 0.08);
|
||||||
|
--skeleton-accent-highlight: rgba(79, 70, 229, 0.14);
|
||||||
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
|
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
|
||||||
--grid-line: rgba(79, 70, 229, 0.04);
|
--grid-line: rgba(79, 70, 229, 0.04);
|
||||||
--glow: rgba(79, 70, 229, 0.06);
|
--glow: rgba(79, 70, 229, 0.06);
|
||||||
@@ -172,6 +180,44 @@ body::after {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-skeleton {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--skeleton-base) 0%,
|
||||||
|
var(--skeleton-highlight) 50%,
|
||||||
|
var(--skeleton-base) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-skeleton--accent {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--skeleton-accent-base) 0%,
|
||||||
|
var(--skeleton-accent-highlight) 50%,
|
||||||
|
var(--skeleton-accent-base) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.theme-skeleton,
|
||||||
|
.theme-skeleton--accent {
|
||||||
|
animation: none;
|
||||||
|
background-position: 50% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Responsive container ─── */
|
/* ─── Responsive container ─── */
|
||||||
/* Base: padding + centering only. Width is a CSS variable so modifiers cascade. */
|
/* Base: padding + centering only. Width is a CSS variable so modifiers cascade. */
|
||||||
.page-container {
|
.page-container {
|
||||||
|
|||||||
Reference in New Issue
Block a user