mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:58:31 +00:00
Feat: Ingest UI
This commit is contained in:
parent
02c7b90374
commit
a9034fb448
75
apps/webapp/app/components/api/api-table.tsx
Normal file
75
apps/webapp/app/components/api/api-table.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { type PersonalAccessToken, useTokensColumns } from "./columns";
|
||||
|
||||
export const APITable = ({
|
||||
personalAccessTokens,
|
||||
}: {
|
||||
personalAccessTokens: PersonalAccessToken[];
|
||||
}) => {
|
||||
const columns = useTokensColumns();
|
||||
const table = useReactTable({
|
||||
data: personalAccessTokens,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="text-sm">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="w-[90%] py-1">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
116
apps/webapp/app/components/api/columns.tsx
Normal file
116
apps/webapp/app/components/api/columns.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "../ui";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import React from "react";
|
||||
|
||||
export interface PersonalAccessToken {
|
||||
name: string;
|
||||
id: string;
|
||||
obfuscatedToken: string;
|
||||
lastAccessedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const useTokensColumns = (): Array<ColumnDef<PersonalAccessToken>> => {
|
||||
const fetcher = useFetcher();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
fetcher.submit({ id }, { method: "DELETE", action: "/home/api" });
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => {
|
||||
return <span>Name</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="py-2capitalize flex items-center gap-1 py-2">
|
||||
{row.original.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "obfuscatedToken",
|
||||
header: () => {
|
||||
return <span>Token</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
{row.original.obfuscatedToken}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lastAccessedAt",
|
||||
header: () => {
|
||||
return <span>Last accessed</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex min-w-[200px] items-center gap-1">
|
||||
{row.original.lastAccessedAt
|
||||
? format(row.original.lastAccessedAt, "MMM d, yyyy")
|
||||
: "Never"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: () => {
|
||||
return <span>Actions</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost">Delete</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete
|
||||
your API token.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
1
apps/webapp/app/components/api/index.ts
Normal file
1
apps/webapp/app/components/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./api-table";
|
||||
@ -1 +1,2 @@
|
||||
export * from "./ingest";
|
||||
export * from "./search";
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { PlusIcon, Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { EpisodeType } from "@core/types";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
|
||||
export const IngestBodyRequest = z.object({
|
||||
episodeBody: z.string(),
|
||||
@ -16,10 +17,27 @@ export const IngestBodyRequest = z.object({
|
||||
|
||||
export const Ingest = () => {
|
||||
const [text, setText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
fetcher.submit(
|
||||
{
|
||||
episodeBody: text,
|
||||
type: "TEXT",
|
||||
referenceTime: new Date().toISOString(),
|
||||
source: "local",
|
||||
},
|
||||
{ method: "POST", action: "/home/dashboard" },
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = fetcher.state === "submitting";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<form method="POST" action="/home/dashboard" className="flex flex-col">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
<input type="hidden" name="type" value="TEXT" />
|
||||
<input
|
||||
type="hidden"
|
||||
@ -27,18 +45,27 @@ export const Ingest = () => {
|
||||
value={new Date().toISOString()}
|
||||
/>
|
||||
<input type="hidden" name="source" value="local" />
|
||||
|
||||
<Textarea
|
||||
name="episodeBody"
|
||||
value={text}
|
||||
placeholder="Tell what you want to add"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="submit" variant="secondary" className="gap-1">
|
||||
<PlusIcon size={16} />
|
||||
Add
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="gap-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<PlusIcon size={16} />
|
||||
)}
|
||||
{isLoading ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
98
apps/webapp/app/components/dashboard/search.tsx
Normal file
98
apps/webapp/app/components/dashboard/search.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { PlusIcon, SearchIcon, Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { EpisodeType } from "@core/types";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
|
||||
export const Search = () => {
|
||||
const [text, setText] = useState("");
|
||||
const fetcher = useFetcher<undefined | Record<string, string[]>>();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
fetcher.submit(
|
||||
{ query: text },
|
||||
{ method: "POST", action: "/home/dashboard" },
|
||||
);
|
||||
};
|
||||
|
||||
const searchResults = () => {
|
||||
const data = fetcher.data as {
|
||||
episodes?: string[];
|
||||
facts?: string[];
|
||||
};
|
||||
|
||||
if (
|
||||
(!data.episodes || data.episodes.length === 0) &&
|
||||
(!data.facts || data.facts.length === 0)
|
||||
) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<p className="text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{data.episodes && data.episodes.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">Episodes</h3>
|
||||
{data.episodes.map((episode, index) => (
|
||||
<div key={index} className="bg-secondary mb-2 rounded-lg p-3">
|
||||
{episode}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.facts && data.facts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">Facts</h3>
|
||||
{data.facts.map((fact, index) => (
|
||||
<div key={index} className="bg-secondary mb-2 rounded-lg p-3">
|
||||
{fact}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = fetcher.state === "submitting";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
<Textarea
|
||||
name="query"
|
||||
value={text}
|
||||
placeholder="What do you want to search"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="gap-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<SearchIcon size={16} />
|
||||
)}
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{fetcher?.data && searchResults()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -114,31 +114,8 @@ export function GraphPopovers({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Name:
|
||||
</span>
|
||||
{nodePopupContent?.node.name || "Unknown"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm break-words">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
UUID:
|
||||
</span>
|
||||
{nodePopupContent?.node.uuid || "Unknown"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm break-words">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Created:
|
||||
</span>
|
||||
{nodePopupContent?.node.created_at &&
|
||||
formatDate(nodePopupContent?.node.created_at)}
|
||||
</p>
|
||||
|
||||
{attributesToDisplay.length > 0 && (
|
||||
<div className="border-border border-t pt-2">
|
||||
<p className="mb-2 text-sm font-medium text-black dark:text-white">
|
||||
Properties:
|
||||
</p>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
{attributesToDisplay.map(({ key, value }) => (
|
||||
<p key={key} className="text-sm">
|
||||
@ -155,33 +132,6 @@ export function GraphPopovers({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodePopupContent?.node.summary && (
|
||||
<div className="border-border border-t pt-2">
|
||||
<p className="mb-1 text-sm font-medium text-black dark:text-white">
|
||||
Summary:
|
||||
</p>
|
||||
<div
|
||||
className="relative max-h-[200px] overflow-y-auto"
|
||||
style={{
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "rgba(155, 155, 155, 0.5) transparent",
|
||||
pointerEvents: "auto",
|
||||
touchAction: "auto",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget;
|
||||
target.scrollTop += e.deltaY;
|
||||
}}
|
||||
>
|
||||
<p className="text-muted-foreground pr-4 text-sm break-words">
|
||||
{nodePopupContent.node.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@ -200,11 +150,7 @@ export function GraphPopovers({
|
||||
>
|
||||
<div className="bg-grayAlpha-100 mb-4 rounded-md p-2">
|
||||
<p className="text-sm break-all">
|
||||
{edgePopupContent?.source.name || "Unknown"} →{" "}
|
||||
<span className="font-medium">
|
||||
{edgePopupContent?.relation.name || "Unknown"}
|
||||
</span>{" "}
|
||||
→ {edgePopupContent?.target.name || "Unknown"}
|
||||
Episode → {edgePopupContent?.target.name || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -220,63 +166,14 @@ export function GraphPopovers({
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Type:
|
||||
</span>
|
||||
{edgePopupContent?.relation.name || "Unknown"}
|
||||
{edgePopupContent?.relation.type || "Unknown"}
|
||||
</p>
|
||||
{edgePopupContent?.relation.fact && (
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Fact:
|
||||
</span>
|
||||
{edgePopupContent.relation.fact}
|
||||
</p>
|
||||
)}
|
||||
{edgePopupContent?.relation.episodes?.length ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-black dark:text-white">
|
||||
Episodes:
|
||||
</p>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{edgePopupContent.relation.episodes.map((episode) => (
|
||||
<span
|
||||
key={episode}
|
||||
className="bg-muted rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
{episode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Created:
|
||||
</span>
|
||||
{formatDate(edgePopupContent?.relation.created_at)}
|
||||
{formatDate(edgePopupContent?.relation.createdAt)}
|
||||
</p>
|
||||
{edgePopupContent?.relation.valid_at && (
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Valid From:
|
||||
</span>
|
||||
{formatDate(edgePopupContent.relation.valid_at)}
|
||||
</p>
|
||||
)}
|
||||
{edgePopupContent?.relation.expired_at && (
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Expired At:
|
||||
</span>
|
||||
{formatDate(edgePopupContent.relation.expired_at)}
|
||||
</p>
|
||||
)}
|
||||
{edgePopupContent?.relation.invalid_at && (
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
<span className="mr-2 text-sm font-medium text-black dark:text-white">
|
||||
Invalid At:
|
||||
</span>
|
||||
{formatDate(edgePopupContent.relation.invalid_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@ -4,8 +4,7 @@ export interface Node {
|
||||
summary?: string;
|
||||
labels?: string[];
|
||||
attributes?: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
@ -13,14 +12,7 @@ export interface Edge {
|
||||
source_node_uuid: string;
|
||||
target_node_uuid: string;
|
||||
type: string;
|
||||
name: string;
|
||||
fact?: string;
|
||||
episodes?: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
valid_at?: string;
|
||||
expired_at?: string;
|
||||
invalid_at?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RawTriplet {
|
||||
|
||||
@ -16,8 +16,7 @@ export function toGraphNode(node: Node): GraphNode {
|
||||
value: node.name,
|
||||
uuid: node.uuid,
|
||||
name: node.name,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
createdAt: node.createdAt,
|
||||
attributes: node.attributes,
|
||||
summary: node.summary,
|
||||
labels: node.labels,
|
||||
@ -28,7 +27,7 @@ export function toGraphNode(node: Node): GraphNode {
|
||||
export function toGraphEdge(edge: Edge): GraphEdge {
|
||||
return {
|
||||
id: edge.uuid,
|
||||
value: edge.name,
|
||||
value: edge.type,
|
||||
...edge,
|
||||
};
|
||||
}
|
||||
@ -90,9 +89,8 @@ export function createTriplets(edges: Edge[], nodes: Node[]): RawTriplet[] {
|
||||
target_node_uuid: node.uuid,
|
||||
// Use a special type that we can filter out in the Graph component
|
||||
type: "_isolated_node_",
|
||||
name: "", // Empty name so it doesn't show a label
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
|
||||
createdAt: node.createdAt,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
1
apps/webapp/app/components/logs/index.ts
Normal file
1
apps/webapp/app/components/logs/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./ingestion-logs-table";
|
||||
98
apps/webapp/app/components/logs/ingestion-logs-table.tsx
Normal file
98
apps/webapp/app/components/logs/ingestion-logs-table.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
|
||||
// Define the type for your ingestion log
|
||||
export type IngestionLog = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
// Add other fields as needed
|
||||
};
|
||||
|
||||
const useIngestionLogsColumns = (): ColumnDef<IngestionLog>[] => [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: (info) => new Date(info.getValue() as string).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: (info) => info.getValue(),
|
||||
},
|
||||
// Add more columns as needed
|
||||
];
|
||||
|
||||
export const IngestionLogsTable = ({
|
||||
ingestionLogs,
|
||||
}: {
|
||||
ingestionLogs: IngestionLog[];
|
||||
}) => {
|
||||
const columns = useIngestionLogsColumns();
|
||||
const table = useReactTable({
|
||||
data: ingestionLogs,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="text-sm">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="w-[90%] py-2">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No logs found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -6,33 +6,32 @@ import {
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "../ui/sidebar";
|
||||
import { DashboardIcon } from "@radix-ui/react-icons";
|
||||
import { Code, LucideFileStack } from "lucide-react";
|
||||
import { Code, Search } from "lucide-react";
|
||||
import { NavMain } from "./nav-main";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { useWorkspace } from "~/hooks/useWorkspace";
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
url: "/home/dashboard",
|
||||
icon: DashboardIcon,
|
||||
},
|
||||
{
|
||||
title: "API",
|
||||
url: "/api",
|
||||
url: "/home/api",
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "/home/logs",
|
||||
icon: Search,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -45,16 +44,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<a href="#">
|
||||
<span className="text-base font-semibold">
|
||||
{workspace.name}
|
||||
</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<span className="text-base font-semibold">{workspace.name}</span>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
@ -62,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<NavMain items={data.navMain} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarFooter className="p-0">
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import {
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "../ui/sidebar";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { useLocation, useNavigate } from "@remix-run/react";
|
||||
|
||||
export const NavMain = ({
|
||||
items,
|
||||
@ -20,6 +17,7 @@ export const NavMain = ({
|
||||
}[];
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
@ -30,6 +28,7 @@ export const NavMain = ({
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={location.pathname.includes(item.url)}
|
||||
onClick={() => navigate(item.url)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
|
||||
@ -1,22 +1,16 @@
|
||||
import { DotIcon, LogOut, User as UserI } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage, AvatarText } from "../ui/avatar";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { AvatarText } from "../ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "../ui/sidebar";
|
||||
import { SidebarMenu, SidebarMenuItem, useSidebar } from "../ui/sidebar";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { Button } from "../ui";
|
||||
|
||||
export function NavUser({ user }: { user: User }) {
|
||||
const { isMobile } = useSidebar();
|
||||
@ -26,9 +20,10 @@ export function NavUser({ user }: { user: User }) {
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
<Button
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
variant="link"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground ,b-2 mb-2 gap-2 px-2"
|
||||
>
|
||||
<AvatarText
|
||||
text={user.name ?? "User"}
|
||||
@ -40,21 +35,16 @@ export function NavUser({ user }: { user: User }) {
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
side={isMobile ? "bottom" : "top"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{user.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
@ -64,15 +54,11 @@ export function NavUser({ user }: { user: User }) {
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<UserI />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
<DropdownMenuItem
|
||||
className="flex gap-2"
|
||||
onClick={() => (window.location.href = "/logout")}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
63
apps/webapp/app/components/ui/alert.tsx
Normal file
63
apps/webapp/app/components/ui/alert.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
success:
|
||||
"border-success/50 text-success dark:border-success [&>svg]:text-success bg-success/10",
|
||||
warning:
|
||||
"border-warning/50 text-warning dark:border-warning [&>svg]:text-warning bg-warning/10",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive bg-destructive/10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 leading-none font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
132
apps/webapp/app/components/ui/dialog.tsx
Normal file
132
apps/webapp/app/components/ui/dialog.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-grayAlpha-300 fixed inset-0 overflow-auto font-sans",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
closeIcon?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, closeIcon = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<div className="fixed top-0 left-0 z-20 h-[100vh] w-[100vw]">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full !w-[100vw] !max-w-[100%] items-start justify-center p-[calc(0.07px_+_13vh)_12px_13vh] font-sans duration-200",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background-2 shadow-1 border-border z-50 flex max-h-full min-w-[500px] flex-col gap-4 overflow-hidden sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{closeIcon && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center font-sans sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse font-sans sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("font-sans text-lg leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground font-sans text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
127
apps/webapp/app/components/ui/pagination.tsx
Normal file
127
apps/webapp/app/components/ui/pagination.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Button, buttonVariants } from "~/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
125
apps/webapp/app/components/ui/table.tsx
Normal file
125
apps/webapp/app/components/ui/table.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_tr]:border-border text-muted-foreground font-mono [&_tr]:border-b",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@ -40,15 +40,17 @@ async function processUserJob(userId: string, job: any) {
|
||||
await prisma.ingestionQueue.update({
|
||||
where: { id: job.data.queueId },
|
||||
data: {
|
||||
output: episodeDetails,
|
||||
status: IngestionStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
|
||||
// your processing logic
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
await prisma.ingestionQueue.update({
|
||||
where: { id: job.data.queueId },
|
||||
data: {
|
||||
error: err.message,
|
||||
status: IngestionStatus.FAILED,
|
||||
},
|
||||
});
|
||||
@ -86,12 +88,28 @@ export const addToQueue = async (
|
||||
body: z.infer<typeof IngestBodyRequest>,
|
||||
userId: string,
|
||||
) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
Workspace: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.Workspace?.id) {
|
||||
throw new Error(
|
||||
"Workspace ID is required to create an ingestion queue entry.",
|
||||
);
|
||||
}
|
||||
|
||||
const queuePersist = await prisma.ingestionQueue.create({
|
||||
data: {
|
||||
spaceId: body.spaceId,
|
||||
spaceId: body.spaceId ? body.spaceId : null,
|
||||
data: body,
|
||||
status: IngestionStatus.PENDING,
|
||||
priority: 1,
|
||||
workspaceId: user.Workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -78,25 +78,21 @@ export const getNodeLinks = async (userId: string) => {
|
||||
labels: sourceNode.labels,
|
||||
attributes: sourceNode.properties,
|
||||
name: sourceNode.properties.name || "",
|
||||
created_at: sourceNode.properties.created_at || "",
|
||||
updated_at: sourceNode.properties.updated_at || "",
|
||||
createdAt: sourceNode.properties.createdAt || "",
|
||||
},
|
||||
edge: {
|
||||
uuid: edge.identity.toString(),
|
||||
type: edge.type,
|
||||
source_node_uuid: sourceNode.identity.toString(),
|
||||
target_node_uuid: targetNode.identity.toString(),
|
||||
name: edge.properties.name || "",
|
||||
created_at: edge.properties.created_at || "",
|
||||
updated_at: edge.properties.updated_at || "",
|
||||
createdAt: edge.properties.createdAt || "",
|
||||
},
|
||||
targetNode: {
|
||||
uuid: targetNode.identity.toString(),
|
||||
labels: targetNode.labels,
|
||||
attributes: targetNode.properties,
|
||||
name: targetNode.properties.name || "",
|
||||
created_at: targetNode.properties.created_at || "",
|
||||
updated_at: targetNode.properties.updated_at || "",
|
||||
createdAt: edge.properties.createdAt || "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -62,7 +62,6 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
export default function ConfirmBasicDetails() {
|
||||
const lastSubmission = useActionData<typeof action>();
|
||||
const [selectedApps, setSelectedApps] = useState<string[]>([]);
|
||||
|
||||
const [form, fields] = useForm({
|
||||
lastSubmission: lastSubmission as any,
|
||||
|
||||
184
apps/webapp/app/routes/home.api.tsx
Normal file
184
apps/webapp/app/routes/home.api.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import {
|
||||
type LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { Plus, Copy } from "lucide-react";
|
||||
import { Button } from "~/components/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createPersonalAccessToken,
|
||||
getValidPersonalAccessTokens,
|
||||
revokePersonalAccessToken,
|
||||
} from "~/services/personalAccessToken.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { APITable } from "~/components/api";
|
||||
|
||||
export const APIKeyBodyRequest = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const APIKeyDeleteBodyRequest = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, {
|
||||
schema: APIKeyDeleteBodyRequest,
|
||||
});
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const results = await revokePersonalAccessToken(submission.value.id);
|
||||
|
||||
return json(results);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = parse(formData, {
|
||||
schema: APIKeyBodyRequest,
|
||||
});
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const results = await createPersonalAccessToken({
|
||||
name: submission.value.name,
|
||||
userId,
|
||||
});
|
||||
return json(results);
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
|
||||
|
||||
return personalAccessTokens;
|
||||
}
|
||||
|
||||
export default function API() {
|
||||
const personalAccessTokens = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const fetcher = useFetcher<{ token: string }>();
|
||||
const isSubmitting = fetcher.state !== "idle";
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
fetcher.submit({ name }, { method: "POST", action: "/home/api" });
|
||||
setOpen(false);
|
||||
setShowToken(true);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string | undefined) => {
|
||||
text && navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1 text-base">
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage API keys to access your data programmatically. API
|
||||
keys allow secure access to your workspace's data and functionality
|
||||
through our REST API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center gap-1"
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
name="name"
|
||||
placeholder="Enter API key name"
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create API Key"}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showToken} onOpenChange={setShowToken}>
|
||||
<DialogContent className="p-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Your New API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make sure to copy your API key now. You won't be able to see
|
||||
it again!
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-md border p-3">
|
||||
<code className="flex-1 text-sm break-all">
|
||||
{fetcher.data?.token}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(fetcher.data?.token)}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<APITable personalAccessTokens={personalAccessTokens} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
import { useLocalCommonState } from "~/hooks/use-local-state";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@ -15,17 +14,34 @@ import {
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { useActionData } from "@remix-run/react";
|
||||
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
|
||||
import { getNodeLinks } from "~/lib/neo4j.server";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
|
||||
import { GraphVisualization } from "~/components/graph/graph-visualization";
|
||||
import { Search } from "~/components/dashboard";
|
||||
import { SearchBodyRequest } from "./search";
|
||||
import { SearchService } from "~/services/search.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
// Check if this is a search request by looking for query parameter
|
||||
if (formData.has("query")) {
|
||||
// Handle ingest request
|
||||
const submission = parse(formData, { schema: SearchBodyRequest });
|
||||
const searchService = new SearchService();
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const results = await searchService.search(submission.value.query, userId);
|
||||
return json(results);
|
||||
}
|
||||
|
||||
// Handle ingest request
|
||||
const submission = parse(formData, { schema: IngestBodyRequest });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
@ -45,8 +61,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export default function Dashboard() {
|
||||
const nodeLinks = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const actionData = useActionData<typeof action>();
|
||||
|
||||
const [size, setSize] = useState(15);
|
||||
|
||||
return (
|
||||
@ -57,8 +71,8 @@ export default function Dashboard() {
|
||||
order={1}
|
||||
id="home"
|
||||
>
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-3">
|
||||
<h2 className="text-xl"> Graph </h2>
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-3 text-base">
|
||||
<h3 className="text-lg font-medium">Graph</h3>
|
||||
<p className="text-muted-foreground"> Your memory graph </p>
|
||||
|
||||
<div className="bg-background-3 mt-2 grow rounded">
|
||||
@ -86,7 +100,10 @@ export default function Dashboard() {
|
||||
<TabsTrigger value="retrieve">Retrieve</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="ingest">
|
||||
<Ingest actionData={actionData} />
|
||||
<Ingest />
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieve">
|
||||
<Search />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ResizablePanel>
|
||||
|
||||
52
apps/webapp/app/routes/home.logs.tsx
Normal file
52
apps/webapp/app/routes/home.logs.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { IngestionLogsTable } from "~/components/logs";
|
||||
import { getIngestionLogs } from "~/services/ingestionLogs.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const page = Number(url.searchParams.get("page") || 1);
|
||||
|
||||
const { ingestionLogs, pagination } = await getIngestionLogs(userId, page);
|
||||
|
||||
return json({ ingestionLogs, pagination });
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const { ingestionLogs, pagination } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1 text-base">
|
||||
<h2 className="text-lg font-semibold">Logs</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View and monitor your data ingestion logs. These logs show the
|
||||
history of data being loaded into memory, helping you track and
|
||||
debug the ingestion process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IngestionLogsTable ingestionLogs={ingestionLogs} />
|
||||
<div className="mt-4">
|
||||
{Array.from({ length: pagination.pages }, (_, i) => (
|
||||
<Link
|
||||
key={i + 1}
|
||||
to={`?page=${i + 1}`}
|
||||
className={`mx-1 rounded border px-2 py-1 ${
|
||||
pagination.currentPage === i + 1
|
||||
? "bg-gray-200 font-bold"
|
||||
: "bg-white"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/webapp/app/services/ingestionLogs.server.ts
Normal file
46
apps/webapp/app/services/ingestionLogs.server.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function getIngestionLogs(
|
||||
userId: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
Workspace: true,
|
||||
},
|
||||
});
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [ingestionLogs, total] = await Promise.all([
|
||||
prisma.ingestionQueue.findMany({
|
||||
where: {
|
||||
workspaceId: user?.Workspace?.id,
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
prisma.ingestionQueue.count({
|
||||
where: {
|
||||
workspaceId: user?.Workspace?.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ingestionLogs,
|
||||
pagination: {
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
currentPage: page,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -324,6 +324,6 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground text-base;
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,7 @@
|
||||
"@remix-run/server-runtime": "2.16.7",
|
||||
"@remix-run/v1-meta": "^0.1.3",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@tanstack/react-table": "^8.13.2",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"ai": "4.3.14",
|
||||
@ -54,6 +55,7 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"d3": "^7.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^4.18.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"isbot": "^4.1.0",
|
||||
@ -72,6 +74,7 @@
|
||||
"remix-typedjson": "0.3.1",
|
||||
"remix-utils": "^7.7.0",
|
||||
"react-resizable-panels": "^1.0.9",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `workspaceId` to the `IngestionQueue` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "IngestionQueue" ADD COLUMN "output" JSONB,
|
||||
ADD COLUMN "workspaceId" TEXT NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IngestionQueue" ADD CONSTRAINT "IngestionQueue_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -54,8 +54,9 @@ model Workspace {
|
||||
|
||||
integrations String[]
|
||||
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
IngestionQueue IngestionQueue[]
|
||||
}
|
||||
|
||||
enum AuthenticationMethod {
|
||||
@ -174,9 +175,13 @@ model IngestionQueue {
|
||||
|
||||
// Queue metadata
|
||||
data Json // The actual data to be processed
|
||||
output Json? // The processed output data
|
||||
status IngestionStatus
|
||||
priority Int @default(0)
|
||||
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||
|
||||
// Error handling
|
||||
error String?
|
||||
retryCount Int @default(0)
|
||||
|
||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@ -135,6 +135,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.7
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.13.2
|
||||
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
ai:
|
||||
specifier: 4.3.14
|
||||
version: 4.3.14(react@18.3.1)(zod@3.23.8)
|
||||
@ -156,6 +159,9 @@ importers:
|
||||
d3:
|
||||
specifier: ^7.9.0
|
||||
version: 7.9.0
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.13
|
||||
@ -198,6 +204,9 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^1.0.9
|
||||
version: 1.0.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-virtualized:
|
||||
specifier: ^9.22.6
|
||||
version: 9.22.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
remix-auth:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@ -2173,6 +2182,17 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6
|
||||
|
||||
'@tanstack/react-table@8.21.3':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@testing-library/dom@8.20.1':
|
||||
resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==}
|
||||
engines: {node: '>=12'}
|
||||
@ -2969,6 +2989,10 @@ packages:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
clsx@1.2.1:
|
||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@ -3274,6 +3298,9 @@ packages:
|
||||
dataloader@1.4.0:
|
||||
resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==}
|
||||
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
@ -3406,6 +3433,9 @@ packages:
|
||||
dom-accessibility-api@0.5.16:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
dotenv-cli@7.4.4:
|
||||
resolution: {integrity: sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==}
|
||||
hasBin: true
|
||||
@ -5635,6 +5665,9 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-lifecycles-compat@3.0.4:
|
||||
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
|
||||
|
||||
react-refresh@0.14.2:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -5688,6 +5721,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-virtualized@9.22.6:
|
||||
resolution: {integrity: sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==}
|
||||
peerDependencies:
|
||||
react: ^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -8662,6 +8701,14 @@ snapshots:
|
||||
tailwindcss: 4.1.7
|
||||
vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@testing-library/dom@8.20.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@ -9612,6 +9659,8 @@ snapshots:
|
||||
|
||||
clone@1.0.4: {}
|
||||
|
||||
clsx@1.2.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
@ -9925,6 +9974,8 @@ snapshots:
|
||||
|
||||
dataloader@1.4.0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debug@2.6.9:
|
||||
@ -10039,6 +10090,11 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.3
|
||||
csstype: 3.1.3
|
||||
|
||||
dotenv-cli@7.4.4:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@ -12617,6 +12673,8 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-lifecycles-compat@3.0.4: {}
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1):
|
||||
@ -12663,6 +12721,17 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
|
||||
react-virtualized@9.22.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.3
|
||||
clsx: 1.2.1
|
||||
dom-helpers: 5.2.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-lifecycles-compat: 3.0.4
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user