Redesign Resources screen: template panel, subscriptions, hover states, and callback architecture
- Rename ResourceTemplateInput to ResourceTemplatePanel with panel layout (title header, URI with live variable preview, labeled inputs, Read Resource button) - ResourceTemplatePanel manages its own variable state via useState; single onReadResource(uri) callback returns fully resolved URI - Extract ResourceSubscribedItem group component with name, timestamp, and subtle Unsubscribe button - Add hover state to listItem variant via theme classNames + App.css pseudo-selector - Add Refresh button to ResourcePreviewPanel - Refactor ResourcesScreen callbacks: onSelectUri, onSelectTemplate, onReadResource, onSubscribeResource, onUnsubscribeResource replace per-item onClick/onUnsubscribe props; screen wires callbacks to children - Define data-only types (ResourceItem, TemplateListItem, SubscriptionItem, SelectedResource, SelectedTemplate) separating data from callbacks - Extract named .withProps() subcomponents (FooterRow, AnnotationGroup, ActionGroup, Spacer) for AGENTS.md compliance - Update all stories (ResourceTemplatePanel, ResourcesScreen, ConnectedView) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee89ec9beb
commit
a0e763a315
18 changed files with 520 additions and 200 deletions
|
|
@ -105,6 +105,16 @@
|
||||||
animation: inspector-spin 1s linear infinite;
|
animation: inspector-spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── List item hover ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.list-item:hover {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .list-item:hover {
|
||||||
|
background-color: var(--mantine-color-dark-5);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Grid alignment ─────────────────────────────────────── */
|
/* ── Grid alignment ─────────────────────────────────────── */
|
||||||
|
|
||||||
.grid-align-start {
|
.grid-align-start {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const meta: Meta<typeof ResourcePreviewPanel> = {
|
||||||
title: "Groups/ResourcePreviewPanel",
|
title: "Groups/ResourcePreviewPanel",
|
||||||
component: ResourcePreviewPanel,
|
component: ResourcePreviewPanel,
|
||||||
args: {
|
args: {
|
||||||
|
onRefresh: fn(),
|
||||||
onSubscribe: fn(),
|
onSubscribe: fn(),
|
||||||
onUnsubscribe: fn(),
|
onUnsubscribe: fn(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Flex, Group, Stack, Text, Title } from "@mantine/core";
|
import { Button, Flex, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||||
|
|
@ -11,6 +11,7 @@ export interface ResourcePreviewPanelProps {
|
||||||
content: string;
|
content: string;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
isSubscribed: boolean;
|
isSubscribed: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
onSubscribe: () => void;
|
onSubscribe: () => void;
|
||||||
onUnsubscribe: () => void;
|
onUnsubscribe: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +63,20 @@ const MimeText = Text.withProps({
|
||||||
c: "dimmed",
|
c: "dimmed",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FooterRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
});
|
||||||
|
|
||||||
|
const AnnotationGroup = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ActionGroup = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const Spacer = Flex.withProps({});
|
||||||
|
|
||||||
export function ResourcePreviewPanel({
|
export function ResourcePreviewPanel({
|
||||||
uri,
|
uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
|
@ -69,6 +84,7 @@ export function ResourcePreviewPanel({
|
||||||
content,
|
content,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
|
onRefresh,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
onUnsubscribe,
|
onUnsubscribe,
|
||||||
}: ResourcePreviewPanelProps) {
|
}: ResourcePreviewPanelProps) {
|
||||||
|
|
@ -91,12 +107,12 @@ export function ResourcePreviewPanel({
|
||||||
{lastUpdated ? (
|
{lastUpdated ? (
|
||||||
<TimestampText>{formatLastUpdated(lastUpdated)}</TimestampText>
|
<TimestampText>{formatLastUpdated(lastUpdated)}</TimestampText>
|
||||||
) : (
|
) : (
|
||||||
<Flex />
|
<Spacer />
|
||||||
)}
|
)}
|
||||||
<MimeText>{mimeType}</MimeText>
|
<MimeText>{mimeType}</MimeText>
|
||||||
</MetaRow>
|
</MetaRow>
|
||||||
<Group justify="space-between">
|
<FooterRow>
|
||||||
<Group gap="xs">
|
<AnnotationGroup>
|
||||||
{annotations?.audience && (
|
{annotations?.audience && (
|
||||||
<AnnotationBadge label={annotations.audience} variant="audience" />
|
<AnnotationBadge label={annotations.audience} variant="audience" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -106,12 +122,17 @@ export function ResourcePreviewPanel({
|
||||||
variant="priority"
|
variant="priority"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</AnnotationGroup>
|
||||||
<SubscribeButton
|
<ActionGroup>
|
||||||
subscribed={isSubscribed}
|
<Button variant="subtle" size="sm" onClick={onRefresh}>
|
||||||
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
|
Refresh
|
||||||
/>
|
</Button>
|
||||||
</Group>
|
<SubscribeButton
|
||||||
|
subscribed={isSubscribed}
|
||||||
|
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</FooterRow>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
import { ResourceSubscribedItem } from "./ResourceSubscribedItem";
|
||||||
|
|
||||||
|
const meta: Meta<typeof ResourceSubscribedItem> = {
|
||||||
|
title: "Groups/ResourceSubscribedItem",
|
||||||
|
component: ResourceSubscribedItem,
|
||||||
|
args: {
|
||||||
|
onUnsubscribe: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ResourceSubscribedItem>;
|
||||||
|
|
||||||
|
export const WithTimestamp: Story = {
|
||||||
|
args: {
|
||||||
|
name: "config.json",
|
||||||
|
lastUpdated: "2026-03-17T10:30:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutTimestamp: Story = {
|
||||||
|
args: {
|
||||||
|
name: "schema.sql",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface ResourceSubscribedItemProps {
|
||||||
|
name: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
onUnsubscribe: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NameText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
fw: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TimestampText = Text.withProps({
|
||||||
|
size: "xs",
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const SubtleButton = Button.withProps({
|
||||||
|
variant: "subtle",
|
||||||
|
size: "xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ItemRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ResourceSubscribedItem({
|
||||||
|
name,
|
||||||
|
lastUpdated,
|
||||||
|
onUnsubscribe,
|
||||||
|
}: ResourceSubscribedItemProps) {
|
||||||
|
return (
|
||||||
|
<ItemRow>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<NameText>{name}</NameText>
|
||||||
|
{lastUpdated && <TimestampText>{lastUpdated}</TimestampText>}
|
||||||
|
</Stack>
|
||||||
|
<SubtleButton onClick={onUnsubscribe}>Unsubscribe</SubtleButton>
|
||||||
|
</ItemRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { fn } from "storybook/test";
|
|
||||||
import { ResourceTemplateInput } from "./ResourceTemplateInput";
|
|
||||||
|
|
||||||
const meta: Meta<typeof ResourceTemplateInput> = {
|
|
||||||
title: "Groups/ResourceTemplateInput",
|
|
||||||
component: ResourceTemplateInput,
|
|
||||||
args: {
|
|
||||||
onVariableChange: fn(),
|
|
||||||
onSubmit: fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ResourceTemplateInput>;
|
|
||||||
|
|
||||||
export const SingleVariable: Story = {
|
|
||||||
args: {
|
|
||||||
template: "user/{id}",
|
|
||||||
variables: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MultipleVariables: Story = {
|
|
||||||
args: {
|
|
||||||
template: "org/{orgId}/user/{userId}",
|
|
||||||
variables: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FilledIn: Story = {
|
|
||||||
args: {
|
|
||||||
template: "org/{orgId}/user/{userId}",
|
|
||||||
variables: {
|
|
||||||
orgId: "acme-corp",
|
|
||||||
userId: "user-42",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { Button, Group, Text, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
export interface ResourceTemplateInputProps {
|
|
||||||
template: string;
|
|
||||||
variables: Record<string, string>;
|
|
||||||
onVariableChange: (name: string, value: string) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplatePart {
|
|
||||||
type: "static" | "variable";
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTemplate(template: string): TemplatePart[] {
|
|
||||||
const parts: TemplatePart[] = [];
|
|
||||||
const regex = /\{(\w+)\}/g;
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = regex.exec(template)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push({
|
|
||||||
type: "static",
|
|
||||||
value: template.slice(lastIndex, match.index),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
parts.push({ type: "variable", value: match[1] });
|
|
||||||
lastIndex = regex.lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < template.length) {
|
|
||||||
parts.push({ type: "static", value: template.slice(lastIndex) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StaticPart = Text.withProps({
|
|
||||||
size: "sm",
|
|
||||||
pb: 6,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ResourceTemplateInput({
|
|
||||||
template,
|
|
||||||
variables,
|
|
||||||
onVariableChange,
|
|
||||||
onSubmit,
|
|
||||||
}: ResourceTemplateInputProps) {
|
|
||||||
const parts = parseTemplate(template);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap="xs" align="flex-end">
|
|
||||||
{parts.map((part, index) =>
|
|
||||||
part.type === "static" ? (
|
|
||||||
<StaticPart key={index}>{part.value}</StaticPart>
|
|
||||||
) : (
|
|
||||||
<TextInput
|
|
||||||
key={part.value}
|
|
||||||
size="sm"
|
|
||||||
placeholder={part.value}
|
|
||||||
value={variables[part.value] ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onVariableChange(part.value, e.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={onSubmit}>
|
|
||||||
Go
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
import { ResourceTemplatePanel } from "./ResourceTemplatePanel";
|
||||||
|
|
||||||
|
const meta: Meta<typeof ResourceTemplatePanel> = {
|
||||||
|
title: "Groups/ResourceTemplatePanel",
|
||||||
|
component: ResourceTemplatePanel,
|
||||||
|
args: {
|
||||||
|
onReadResource: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ResourceTemplatePanel>;
|
||||||
|
|
||||||
|
export const SingleVariable: Story = {
|
||||||
|
args: {
|
||||||
|
name: "User Profile",
|
||||||
|
uriTemplate: "file:///users/{userId}/profile",
|
||||||
|
description: "Fetch a user profile by their unique identifier.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleVariables: Story = {
|
||||||
|
args: {
|
||||||
|
name: "Table Row",
|
||||||
|
title: "Database Table Row",
|
||||||
|
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
|
||||||
|
description: "Access a specific row in a database table.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithAnnotations: Story = {
|
||||||
|
args: {
|
||||||
|
name: "Dynamic Text Resource",
|
||||||
|
uriTemplate: "resource://dynamic/{resourceId}",
|
||||||
|
description:
|
||||||
|
"Plaintext dynamic resource fabricated from the {resourceId} variable, which must be an integer.",
|
||||||
|
annotations: { audience: "developer", priority: 0.8 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoDescription: Story = {
|
||||||
|
args: {
|
||||||
|
name: "Simple Template",
|
||||||
|
uriTemplate: "file:///data/{filename}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Button, Group, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||||
|
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||||
|
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||||
|
|
||||||
|
export interface ResourceTemplatePanelProps {
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
uriTemplate: string;
|
||||||
|
description?: string;
|
||||||
|
annotations?: { audience?: string; priority?: number };
|
||||||
|
onReadResource: (uri: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVariableNames(uriTemplate: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
const regex = /\{(\w+)\}/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(uriTemplate)) !== null) {
|
||||||
|
names.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUri(
|
||||||
|
uriTemplate: string,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewUri(
|
||||||
|
uriTemplate: string,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||||
|
variables[key]?.length > 0 ? variables[key] : match,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityLabel(priority: number): string {
|
||||||
|
if (priority >= 0.7) return "priority: high";
|
||||||
|
if (priority >= 0.4) return "priority: medium";
|
||||||
|
return "priority: low";
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const UriGroup = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const UriText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
c: "blue",
|
||||||
|
truncate: "end",
|
||||||
|
});
|
||||||
|
|
||||||
|
const DescriptionText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const FooterRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
});
|
||||||
|
|
||||||
|
const AnnotationGroup = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ResourceTemplatePanel({
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
uriTemplate,
|
||||||
|
description,
|
||||||
|
annotations,
|
||||||
|
onReadResource,
|
||||||
|
}: ResourceTemplatePanelProps) {
|
||||||
|
const variableNames = useMemo(
|
||||||
|
() => parseVariableNames(uriTemplate),
|
||||||
|
[uriTemplate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [variables, setVariables] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(variableNames.map((n) => [n, ""])),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleVariableChange(varName: string, value: string) {
|
||||||
|
setVariables((prev) => ({ ...prev, [varName]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
onReadResource(resolveUri(uriTemplate, variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<HeaderRow>
|
||||||
|
<Title order={4}>{title ?? name} Template</Title>
|
||||||
|
<UriGroup>
|
||||||
|
<UriText>{previewUri(uriTemplate, variables)}</UriText>
|
||||||
|
<CopyButton value={previewUri(uriTemplate, variables)} />
|
||||||
|
</UriGroup>
|
||||||
|
</HeaderRow>
|
||||||
|
{description && <DescriptionText>{description}</DescriptionText>}
|
||||||
|
<Stack gap="sm">
|
||||||
|
{variableNames.map((varName) => (
|
||||||
|
<TextInput
|
||||||
|
key={varName}
|
||||||
|
label={varName}
|
||||||
|
placeholder={`Enter ${varName}`}
|
||||||
|
value={variables[varName] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleVariableChange(varName, e.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<FooterRow>
|
||||||
|
<AnnotationGroup>
|
||||||
|
{annotations?.audience && (
|
||||||
|
<AnnotationBadge label={annotations.audience} variant="audience" />
|
||||||
|
)}
|
||||||
|
{annotations?.priority !== undefined && (
|
||||||
|
<AnnotationBadge
|
||||||
|
label={priorityLabel(annotations.priority)}
|
||||||
|
variant="priority"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnnotationGroup>
|
||||||
|
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||||
|
Read Resource
|
||||||
|
</Button>
|
||||||
|
</FooterRow>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { fn } from "storybook/test";
|
import { fn } from "storybook/test";
|
||||||
import { ResourcesScreen } from "./ResourcesScreen";
|
import { ResourcesScreen } from "./ResourcesScreen";
|
||||||
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
|
import type {
|
||||||
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
|
ResourceItem,
|
||||||
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
TemplateListItem,
|
||||||
|
SelectedResource,
|
||||||
|
SelectedTemplate,
|
||||||
|
} from "./ResourcesScreen";
|
||||||
|
|
||||||
const meta: Meta<typeof ResourcesScreen> = {
|
const meta: Meta<typeof ResourcesScreen> = {
|
||||||
title: "Screens/ResourcesScreen",
|
title: "Screens/ResourcesScreen",
|
||||||
|
|
@ -13,7 +16,11 @@ const meta: Meta<typeof ResourcesScreen> = {
|
||||||
searchText: "",
|
searchText: "",
|
||||||
onSearchChange: fn(),
|
onSearchChange: fn(),
|
||||||
onRefreshList: fn(),
|
onRefreshList: fn(),
|
||||||
onSelectResource: fn(),
|
onSelectUri: fn(),
|
||||||
|
onSelectTemplate: fn(),
|
||||||
|
onReadResource: fn(),
|
||||||
|
onSubscribeResource: fn(),
|
||||||
|
onUnsubscribeResource: fn(),
|
||||||
listChanged: false,
|
listChanged: false,
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
templates: [],
|
templates: [],
|
||||||
|
|
@ -23,45 +30,41 @@ const meta: Meta<typeof ResourcesScreen> = {
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof ResourcesScreen>;
|
type Story = StoryObj<typeof ResourcesScreen>;
|
||||||
|
|
||||||
const sampleResources: ResourceListItemProps[] = [
|
const sampleResources: ResourceItem[] = [
|
||||||
{
|
{
|
||||||
name: "config.json",
|
name: "config.json",
|
||||||
uri: "file:///config.json",
|
uri: "file:///config.json",
|
||||||
annotations: { audience: "developer", priority: 0.8 },
|
annotations: { audience: "developer", priority: 0.8 },
|
||||||
selected: false,
|
selected: false,
|
||||||
onClick: fn(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "README.md",
|
name: "README.md",
|
||||||
uri: "file:///README.md",
|
uri: "file:///README.md",
|
||||||
selected: false,
|
selected: false,
|
||||||
onClick: fn(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "schema.sql",
|
name: "schema.sql",
|
||||||
uri: "file:///schema.sql",
|
uri: "file:///schema.sql",
|
||||||
annotations: { priority: 0.5 },
|
annotations: { priority: 0.5 },
|
||||||
selected: false,
|
selected: false,
|
||||||
onClick: fn(),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const sampleTemplates: ResourceTemplateInputProps[] = [
|
const sampleTemplates: TemplateListItem[] = [
|
||||||
{
|
{
|
||||||
template: "file:///users/{userId}/profile",
|
name: "User Profile",
|
||||||
variables: { userId: "" },
|
uriTemplate: "file:///users/{userId}/profile",
|
||||||
onVariableChange: fn(),
|
selected: false,
|
||||||
onSubmit: fn(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
template: "db://tables/{tableName}/rows/{rowId}",
|
name: "Table Row",
|
||||||
variables: { tableName: "", rowId: "" },
|
title: "Database Table Row",
|
||||||
onVariableChange: fn(),
|
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
|
||||||
onSubmit: fn(),
|
selected: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedResourceProps: ResourcePreviewPanelProps = {
|
const selectedResourceData: SelectedResource = {
|
||||||
uri: "file:///config.json",
|
uri: "file:///config.json",
|
||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
annotations: { audience: "developer", priority: 0.8 },
|
annotations: { audience: "developer", priority: 0.8 },
|
||||||
|
|
@ -76,8 +79,12 @@ const selectedResourceProps: ResourcePreviewPanelProps = {
|
||||||
),
|
),
|
||||||
lastUpdated: "2026-03-17T10:30:00Z",
|
lastUpdated: "2026-03-17T10:30:00Z",
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
onSubscribe: fn(),
|
};
|
||||||
onUnsubscribe: fn(),
|
|
||||||
|
const selectedTemplateData: SelectedTemplate = {
|
||||||
|
name: "User Profile",
|
||||||
|
uriTemplate: "file:///users/{userId}/profile",
|
||||||
|
description: "Fetch a user profile by their unique identifier.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithResources: Story = {
|
export const WithResources: Story = {
|
||||||
|
|
@ -91,9 +98,13 @@ export const ResourceSelected: Story = {
|
||||||
resources: sampleResources.map((r) =>
|
resources: sampleResources.map((r) =>
|
||||||
r.uri === "file:///config.json" ? { ...r, selected: true } : r,
|
r.uri === "file:///config.json" ? { ...r, selected: true } : r,
|
||||||
),
|
),
|
||||||
selectedResource: selectedResourceProps,
|
selectedResource: selectedResourceData,
|
||||||
subscriptions: [
|
subscriptions: [
|
||||||
{ name: "config.json", lastUpdated: "2026-03-17T10:30:00Z" },
|
{
|
||||||
|
name: "config.json",
|
||||||
|
uri: "file:///config.json",
|
||||||
|
lastUpdated: "2026-03-17T10:30:00Z",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -105,6 +116,42 @@ export const WithTemplates: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TemplateSelected: Story = {
|
||||||
|
args: {
|
||||||
|
resources: sampleResources,
|
||||||
|
templates: sampleTemplates.map((t) =>
|
||||||
|
t.uriTemplate === "file:///users/{userId}/profile"
|
||||||
|
? { ...t, selected: true }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
selectedTemplate: selectedTemplateData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateWithResource: Story = {
|
||||||
|
args: {
|
||||||
|
resources: sampleResources,
|
||||||
|
templates: sampleTemplates.map((t) =>
|
||||||
|
t.uriTemplate === "file:///users/{userId}/profile"
|
||||||
|
? { ...t, selected: true }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
selectedTemplate: selectedTemplateData,
|
||||||
|
selectedResource: {
|
||||||
|
uri: "file:///users/42/profile",
|
||||||
|
mimeType: "application/json",
|
||||||
|
annotations: { audience: "developer", priority: 0.8 },
|
||||||
|
content: JSON.stringify(
|
||||||
|
{ id: 42, name: "Alice", email: "alice@example.com" },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
lastUpdated: "2026-03-17T11:15:00Z",
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
args: {
|
args: {
|
||||||
resources: [],
|
resources: [],
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,61 @@ import {
|
||||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||||
import { ResourceListItem } from "../../groups/ResourceListItem/ResourceListItem";
|
import { ResourceListItem } from "../../groups/ResourceListItem/ResourceListItem";
|
||||||
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
||||||
import { ResourceTemplateInput } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
|
import { ResourceSubscribedItem } from "../../groups/ResourceSubscribedItem/ResourceSubscribedItem";
|
||||||
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
|
import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel";
|
||||||
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
|
||||||
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
|
|
||||||
|
|
||||||
export interface SubscriptionInfo {
|
export interface ResourceItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
uri: string;
|
||||||
|
annotations?: { audience?: string; priority?: number };
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateListItem {
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
uriTemplate: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionItem {
|
||||||
|
name: string;
|
||||||
|
uri: string;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelectedResource {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
annotations?: { audience?: string; priority?: number };
|
||||||
|
content: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
isSubscribed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedTemplate {
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
uriTemplate: string;
|
||||||
|
description?: string;
|
||||||
|
annotations?: { audience?: string; priority?: number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResourcesScreenProps {
|
export interface ResourcesScreenProps {
|
||||||
resources: ResourceListItemProps[];
|
resources: ResourceItem[];
|
||||||
templates: ResourceTemplateInputProps[];
|
templates: TemplateListItem[];
|
||||||
subscriptions: SubscriptionInfo[];
|
subscriptions: SubscriptionItem[];
|
||||||
selectedResource?: ResourcePreviewPanelProps;
|
selectedResource?: SelectedResource;
|
||||||
|
selectedTemplate?: SelectedTemplate;
|
||||||
listChanged: boolean;
|
listChanged: boolean;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
onSearchChange: (text: string) => void;
|
onSearchChange: (text: string) => void;
|
||||||
onRefreshList: () => void;
|
onRefreshList: () => void;
|
||||||
onSelectResource: (uri: string) => void;
|
onSelectUri: (uri: string) => void;
|
||||||
|
onSelectTemplate: (uriTemplate: string) => void;
|
||||||
|
onReadResource: (uri: string) => void;
|
||||||
|
onSubscribeResource: (uri: string) => void;
|
||||||
|
onUnsubscribeResource: (uri: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageContainer = Container.withProps({
|
const PageContainer = Container.withProps({
|
||||||
|
|
@ -55,18 +90,12 @@ const EmptyState = Text.withProps({
|
||||||
py: "xl",
|
py: "xl",
|
||||||
});
|
});
|
||||||
|
|
||||||
const SubscriptionMeta = Text.withProps({
|
|
||||||
span: true,
|
|
||||||
c: "dimmed",
|
|
||||||
size: "xs",
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatSectionCount(label: string, count: number): string {
|
function formatSectionCount(label: string, count: number): string {
|
||||||
return `${label} (${count})`;
|
return `${label} (${count})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLastUpdated(lastUpdated: string): string {
|
function templateDisplayName(item: TemplateListItem): string {
|
||||||
return ` — ${lastUpdated}`;
|
return item.title ?? item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourcesScreen({
|
export function ResourcesScreen({
|
||||||
|
|
@ -74,10 +103,16 @@ export function ResourcesScreen({
|
||||||
templates,
|
templates,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
selectedResource,
|
selectedResource,
|
||||||
|
selectedTemplate,
|
||||||
listChanged,
|
listChanged,
|
||||||
searchText,
|
searchText,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onRefreshList,
|
onRefreshList,
|
||||||
|
onSelectUri,
|
||||||
|
onSelectTemplate,
|
||||||
|
onReadResource,
|
||||||
|
onSubscribeResource,
|
||||||
|
onUnsubscribeResource,
|
||||||
}: ResourcesScreenProps) {
|
}: ResourcesScreenProps) {
|
||||||
const filteredResources = resources.filter((r) =>
|
const filteredResources = resources.filter((r) =>
|
||||||
r.name.toLowerCase().includes(searchText.toLowerCase()),
|
r.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
|
@ -113,7 +148,14 @@ export function ResourcesScreen({
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{filteredResources.map((resource) => (
|
{filteredResources.map((resource) => (
|
||||||
<ResourceListItem key={resource.uri} {...resource} />
|
<ResourceListItem
|
||||||
|
key={resource.uri}
|
||||||
|
name={resource.name}
|
||||||
|
uri={resource.uri}
|
||||||
|
annotations={resource.annotations}
|
||||||
|
selected={resource.selected}
|
||||||
|
onClick={() => onSelectUri(resource.uri)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
|
|
@ -126,9 +168,12 @@ export function ResourcesScreen({
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<ResourceTemplateInput
|
<ResourceListItem
|
||||||
key={template.template}
|
key={template.uriTemplate}
|
||||||
{...template}
|
name={templateDisplayName(template)}
|
||||||
|
uri={template.uriTemplate}
|
||||||
|
selected={template.selected}
|
||||||
|
onClick={() => onSelectTemplate(template.uriTemplate)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
@ -142,14 +187,12 @@ export function ResourcesScreen({
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{subscriptions.map((sub) => (
|
{subscriptions.map((sub) => (
|
||||||
<Text key={sub.name} size="sm">
|
<ResourceSubscribedItem
|
||||||
{sub.name}
|
key={sub.name}
|
||||||
{sub.lastUpdated && (
|
name={sub.name}
|
||||||
<SubscriptionMeta>
|
lastUpdated={sub.lastUpdated}
|
||||||
{formatLastUpdated(sub.lastUpdated)}
|
onUnsubscribe={() => onUnsubscribeResource(sub.uri)}
|
||||||
</SubscriptionMeta>
|
/>
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
|
|
@ -160,13 +203,47 @@ export function ResourcesScreen({
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={8}>
|
<Grid.Col span={8}>
|
||||||
<DetailCard>
|
<Stack gap="md">
|
||||||
{selectedResource ? (
|
{selectedTemplate ? (
|
||||||
<ResourcePreviewPanel {...selectedResource} />
|
<>
|
||||||
|
<DetailCard>
|
||||||
|
<ResourceTemplatePanel
|
||||||
|
{...selectedTemplate}
|
||||||
|
onReadResource={onReadResource}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
{selectedResource && (
|
||||||
|
<DetailCard>
|
||||||
|
<ResourcePreviewPanel
|
||||||
|
{...selectedResource}
|
||||||
|
onRefresh={() => onReadResource(selectedResource.uri)}
|
||||||
|
onSubscribe={() =>
|
||||||
|
onSubscribeResource(selectedResource.uri)
|
||||||
|
}
|
||||||
|
onUnsubscribe={() =>
|
||||||
|
onUnsubscribeResource(selectedResource.uri)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : selectedResource ? (
|
||||||
|
<DetailCard>
|
||||||
|
<ResourcePreviewPanel
|
||||||
|
{...selectedResource}
|
||||||
|
onRefresh={() => onReadResource(selectedResource.uri)}
|
||||||
|
onSubscribe={() => onSubscribeResource(selectedResource.uri)}
|
||||||
|
onUnsubscribe={() =>
|
||||||
|
onUnsubscribeResource(selectedResource.uri)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState>Select a resource to preview</EmptyState>
|
<DetailCard>
|
||||||
|
<EmptyState>Select a resource to preview</EmptyState>
|
||||||
|
</DetailCard>
|
||||||
)}
|
)}
|
||||||
</DetailCard>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|
|
||||||
|
|
@ -249,57 +249,66 @@ export const ResourcesActive: Story = {
|
||||||
name: "config.json",
|
name: "config.json",
|
||||||
uri: "file:///config.json",
|
uri: "file:///config.json",
|
||||||
annotations: { audience: "developer", priority: 0.8 },
|
annotations: { audience: "developer", priority: 0.8 },
|
||||||
selected: true,
|
selected: false,
|
||||||
onClick: fn(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "README.md",
|
name: "README.md",
|
||||||
uri: "file:///README.md",
|
uri: "file:///README.md",
|
||||||
selected: false,
|
selected: false,
|
||||||
onClick: fn(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "schema.sql",
|
name: "schema.sql",
|
||||||
uri: "file:///schema.sql",
|
uri: "file:///schema.sql",
|
||||||
annotations: { priority: 0.5 },
|
annotations: { priority: 0.5 },
|
||||||
selected: false,
|
selected: false,
|
||||||
onClick: fn(),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
templates={[
|
templates={[
|
||||||
{
|
{
|
||||||
template: "file:///users/{userId}/profile",
|
name: "User Profile",
|
||||||
variables: { userId: "" },
|
uriTemplate: "file:///users/{userId}/profile",
|
||||||
onVariableChange: fn(),
|
selected: true,
|
||||||
onSubmit: fn(),
|
},
|
||||||
|
{
|
||||||
|
name: "Table Row",
|
||||||
|
title: "Database Table Row",
|
||||||
|
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
|
||||||
|
selected: false,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
subscriptions={[
|
subscriptions={[
|
||||||
{ name: "config.json", lastUpdated: "2026-03-17T10:30:00Z" },
|
{
|
||||||
|
name: "config.json",
|
||||||
|
uri: "file:///config.json",
|
||||||
|
lastUpdated: "2026-03-17T10:30:00Z",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
|
selectedTemplate={{
|
||||||
|
name: "User Profile",
|
||||||
|
uriTemplate: "file:///users/{userId}/profile",
|
||||||
|
description: "Fetch a user profile by their unique identifier.",
|
||||||
|
}}
|
||||||
selectedResource={{
|
selectedResource={{
|
||||||
uri: "file:///config.json",
|
uri: "file:///users/42/profile",
|
||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
annotations: { audience: "developer", priority: 0.8 },
|
annotations: { audience: "developer", priority: 0.8 },
|
||||||
content: JSON.stringify(
|
content: JSON.stringify(
|
||||||
{
|
{ id: 42, name: "Alice", email: "alice@example.com" },
|
||||||
name: "my-project",
|
|
||||||
version: "1.0.0",
|
|
||||||
settings: { debug: true, logLevel: "info" },
|
|
||||||
},
|
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
lastUpdated: "2026-03-17T10:30:00Z",
|
lastUpdated: "2026-03-17T11:15:00Z",
|
||||||
isSubscribed: true,
|
isSubscribed: false,
|
||||||
onSubscribe: fn(),
|
|
||||||
onUnsubscribe: fn(),
|
|
||||||
}}
|
}}
|
||||||
listChanged={false}
|
listChanged={false}
|
||||||
searchText=""
|
searchText=""
|
||||||
onSearchChange={fn()}
|
onSearchChange={fn()}
|
||||||
onRefreshList={fn()}
|
onRefreshList={fn()}
|
||||||
onSelectResource={fn()}
|
onSelectUri={fn()}
|
||||||
|
onSelectTemplate={fn()}
|
||||||
|
onReadResource={fn()}
|
||||||
|
onSubscribeResource={fn()}
|
||||||
|
onUnsubscribeResource={fn()}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { UnstyledButton } from "@mantine/core";
|
import { UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
export const ThemeUnstyledButton = UnstyledButton.extend({
|
export const ThemeUnstyledButton = UnstyledButton.extend({
|
||||||
|
classNames: (_theme, props) => {
|
||||||
|
if (props.variant === "listItem") return { root: "list-item" };
|
||||||
|
return {};
|
||||||
|
},
|
||||||
styles: (_theme, props) => {
|
styles: (_theme, props) => {
|
||||||
if (props.variant === "listItem") {
|
if (props.variant === "listItem") {
|
||||||
return { root: { borderRadius: "var(--mantine-radius-md)" } };
|
return { root: { borderRadius: "var(--mantine-radius-md)" } };
|
||||||
|
|
|
||||||
BIN
specification/screenshots/v1-resource-screen.png
Normal file
BIN
specification/screenshots/v1-resource-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
BIN
specification/screenshots/v1-resource-template-read.png
Normal file
BIN
specification/screenshots/v1-resource-template-read.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
BIN
specification/screenshots/v1-resource-template-selected.png
Normal file
BIN
specification/screenshots/v1-resource-template-selected.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
specification/screenshots/v1-resource-uri-selected.png
Normal file
BIN
specification/screenshots/v1-resource-uri-selected.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
specification/screenshots/v2-resources-screen.png
Normal file
BIN
specification/screenshots/v2-resources-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Loading…
Reference in a new issue