teamshare-bridge 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/daemon.js +36 -0
- package/dist/bridge/daemon.js.map +1 -1
- package/dist/bridge/local-server.d.ts +1 -1
- package/dist/bridge/spawn.d.ts +2 -0
- package/dist/bridge/spawn.js +17 -0
- package/dist/bridge/spawn.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +1 -0
- package/dist/cli/commands/audit.js +75 -0
- package/dist/cli/commands/audit.js.map +1 -0
- package/dist/cli/commands/build.js +9 -6
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/doc.js +1 -1
- package/dist/cli/commands/doc.js.map +1 -1
- package/dist/cli/commands/run.js +3 -2
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +1 -0
- package/dist/cli/commands/scaffold.js +111 -0
- package/dist/cli/commands/scaffold.js.map +1 -0
- package/dist/cli/commands/worker.d.ts +2 -0
- package/dist/cli/commands/worker.js +110 -0
- package/dist/cli/commands/worker.js.map +1 -0
- package/dist/cli/index.js +16 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/lib/api.d.ts +22 -3
- package/dist/lib/api.js +9 -2
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/audit/docs.d.ts +5 -0
- package/dist/lib/audit/docs.js +52 -0
- package/dist/lib/audit/docs.js.map +1 -0
- package/dist/lib/audit/errors.d.ts +15 -0
- package/dist/lib/audit/errors.js +154 -0
- package/dist/lib/audit/errors.js.map +1 -0
- package/dist/lib/audit/gates.d.ts +2 -0
- package/dist/lib/audit/gates.js +57 -0
- package/dist/lib/audit/gates.js.map +1 -0
- package/dist/lib/audit/modularity.d.ts +13 -0
- package/dist/lib/audit/modularity.js +200 -0
- package/dist/lib/audit/modularity.js.map +1 -0
- package/dist/lib/audit/quality.d.ts +9 -0
- package/dist/lib/audit/quality.js +128 -0
- package/dist/lib/audit/quality.js.map +1 -0
- package/dist/lib/audit/rbac.d.ts +14 -0
- package/dist/lib/audit/rbac.js +139 -0
- package/dist/lib/audit/rbac.js.map +1 -0
- package/dist/lib/audit/reporter.d.ts +5 -0
- package/dist/lib/audit/reporter.js +107 -0
- package/dist/lib/audit/reporter.js.map +1 -0
- package/dist/lib/audit/runner.d.ts +2 -0
- package/dist/lib/audit/runner.js +150 -0
- package/dist/lib/audit/runner.js.map +1 -0
- package/dist/lib/audit/security.d.ts +9 -0
- package/dist/lib/audit/security.js +151 -0
- package/dist/lib/audit/security.js.map +1 -0
- package/dist/lib/audit/types.d.ts +67 -0
- package/dist/lib/audit/types.js +26 -0
- package/dist/lib/audit/types.js.map +1 -0
- package/dist/lib/audit/ux.d.ts +9 -0
- package/dist/lib/audit/ux.js +112 -0
- package/dist/lib/audit/ux.js.map +1 -0
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.js +1 -1
- package/dist/lib/llm.js +1 -0
- package/dist/lib/llm.js.map +1 -1
- package/dist/lib/lock.d.ts +1 -1
- package/dist/lib/scaffold/renderer.d.ts +7 -0
- package/dist/lib/scaffold/renderer.js +37 -0
- package/dist/lib/scaffold/renderer.js.map +1 -0
- package/dist/lib/scaffold/structure.d.ts +3 -0
- package/dist/lib/scaffold/structure.js +73 -0
- package/dist/lib/scaffold/structure.js.map +1 -0
- package/dist/lib/scaffold/types.d.ts +34 -0
- package/dist/lib/scaffold/types.js +6 -0
- package/dist/lib/scaffold/types.js.map +1 -0
- package/dist/lib/scaffold/verify.d.ts +9 -0
- package/dist/lib/scaffold/verify.js +48 -0
- package/dist/lib/scaffold/verify.js.map +1 -0
- package/dist/lib/skills.d.ts +2 -2
- package/dist/lib/skills.js +2 -1
- package/dist/lib/skills.js.map +1 -1
- package/package.json +3 -2
- package/skills/teamshare-orchestrator-ask/SKILL.md +14 -0
- package/skills/teamshare-scaffold/SKILL.md +133 -0
- package/templates/backend/.env.example.tpl +13 -0
- package/templates/backend/nest-cli.json.tpl +8 -0
- package/templates/backend/package.json.tpl +41 -0
- package/templates/backend/prisma/schema.prisma.tpl +39 -0
- package/templates/backend/src/app.module.ts.tpl +24 -0
- package/templates/backend/src/common/filters/all-exceptions.filter.ts.tpl +59 -0
- package/templates/backend/src/common/guards/jwt-auth.guard.ts.tpl +27 -0
- package/templates/backend/src/common/guards/rbac.guard.ts.tpl +48 -0
- package/templates/backend/src/common/interceptors/transform.interceptor.ts.tpl +44 -0
- package/templates/backend/src/common/pipes/zod-validation.pipe.ts.tpl +29 -0
- package/templates/backend/src/common/swagger/envelope.schemas.ts.tpl +53 -0
- package/templates/backend/src/common/swagger/error.schemas.ts.tpl +62 -0
- package/templates/backend/src/main.ts.tpl +27 -0
- package/templates/backend/src/modules/example/example.controller.ts.tpl +102 -0
- package/templates/backend/src/modules/example/example.dto.ts.tpl +14 -0
- package/templates/backend/src/modules/example/example.module.ts.tpl +10 -0
- package/templates/backend/src/modules/example/example.service.ts.tpl +45 -0
- package/templates/backend/tsconfig.json.tpl +26 -0
- package/templates/common/.gitignore.tpl +42 -0
- package/templates/common/AGENTS.md.tpl +58 -0
- package/templates/common/docs/adr/README.md +25 -0
- package/templates/common/docs/api-contract.md.tpl +72 -0
- package/templates/web/next.config.ts.tpl +7 -0
- package/templates/web/package.json.tpl +37 -0
- package/templates/web/src/app/globals.css.tpl +48 -0
- package/templates/web/src/app/layout.tsx.tpl +25 -0
- package/templates/web/src/components/shared/app-button.tsx.tpl +59 -0
- package/templates/web/src/components/shared/app-empty-state.tsx.tpl +39 -0
- package/templates/web/src/components/shared/app-list-toolbar.tsx.tpl +63 -0
- package/templates/web/src/components/shared/app-skeleton.tsx.tpl +14 -0
- package/templates/web/src/components/shared/app-table.tsx.tpl +128 -0
- package/templates/web/src/components/shared/index.ts.tpl +5 -0
- package/templates/web/src/hooks/use-list-state.ts.tpl +72 -0
- package/templates/web/src/hooks/use-url-tab.ts.tpl +25 -0
- package/templates/web/src/lib/api/client.ts.tpl +123 -0
- package/templates/web/src/lib/providers.tsx.tpl +35 -0
- package/templates/web/src/lib/query-keys.ts.tpl +21 -0
- package/templates/web/src/lib/utils.ts.tpl +7 -0
- package/templates/web/src/lib/validation/schemas.ts.tpl +15 -0
- package/templates/web/src/modules/example/views/example-list-view.tsx.tpl +98 -0
- package/templates/web/tsconfig.json.tpl +23 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface AppEmptyStateProps {
|
|
5
|
+
icon?: ReactNode;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
action?: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AppEmptyState({
|
|
13
|
+
icon,
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
action,
|
|
17
|
+
className,
|
|
18
|
+
}: AppEmptyStateProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
data-slot="app-empty-state"
|
|
22
|
+
className={cn(
|
|
23
|
+
"flex flex-col items-center justify-center py-12 text-center",
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
{icon && (
|
|
28
|
+
<div className="mb-4 text-tertiary [&_svg]:h-12 [&_svg]:w-12">
|
|
29
|
+
{icon}
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
32
|
+
<h3 className="text-lg font-semibold text-primary">{title}</h3>
|
|
33
|
+
{description && (
|
|
34
|
+
<p className="mt-1 max-w-sm text-sm text-secondary">{description}</p>
|
|
35
|
+
)}
|
|
36
|
+
{action && <div className="mt-4">{action}</div>}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Search } from "lucide-react";
|
|
4
|
+
import { type ReactNode } from "react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface AppListToolbarProps {
|
|
8
|
+
search?: string;
|
|
9
|
+
onSearchChange?: (value: string) => void;
|
|
10
|
+
searchPlaceholder?: string;
|
|
11
|
+
filters?: ReactNode;
|
|
12
|
+
actions?: ReactNode;
|
|
13
|
+
resultCount?: number;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AppListToolbar({
|
|
18
|
+
search,
|
|
19
|
+
onSearchChange,
|
|
20
|
+
searchPlaceholder = "Search...",
|
|
21
|
+
filters,
|
|
22
|
+
actions,
|
|
23
|
+
resultCount,
|
|
24
|
+
className,
|
|
25
|
+
}: AppListToolbarProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
data-slot="app-list-toolbar"
|
|
29
|
+
className={cn(
|
|
30
|
+
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<div className="flex flex-1 items-center gap-3">
|
|
35
|
+
{onSearchChange && (
|
|
36
|
+
<div className="relative flex-1 max-w-sm">
|
|
37
|
+
<Search
|
|
38
|
+
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-tertiary"
|
|
39
|
+
variant="Outline"
|
|
40
|
+
color="currentColor"
|
|
41
|
+
/>
|
|
42
|
+
<input
|
|
43
|
+
type="text"
|
|
44
|
+
placeholder={searchPlaceholder}
|
|
45
|
+
value={search ?? ""}
|
|
46
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
47
|
+
className="h-10 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm placeholder:text-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
{filters}
|
|
52
|
+
</div>
|
|
53
|
+
<div className="flex items-center gap-3">
|
|
54
|
+
{resultCount !== undefined && (
|
|
55
|
+
<span className="text-sm text-secondary">
|
|
56
|
+
{resultCount} result{resultCount !== 1 ? "s" : ""}
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
{actions}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
export interface AppSkeletonProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function AppSkeleton({ className }: AppSkeletonProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
data-slot="app-skeleton"
|
|
11
|
+
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ColumnDef,
|
|
5
|
+
type PaginationState,
|
|
6
|
+
type SortingState,
|
|
7
|
+
flexRender,
|
|
8
|
+
getCoreRowModel,
|
|
9
|
+
getPaginationRowModel,
|
|
10
|
+
getSortedRowModel,
|
|
11
|
+
useReactTable,
|
|
12
|
+
} from "@tanstack/react-table";
|
|
13
|
+
import { type ReactNode } from "react";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
import { AppSkeleton } from "./app-skeleton";
|
|
16
|
+
import { AppEmptyState } from "./app-empty-state";
|
|
17
|
+
|
|
18
|
+
export interface AppTableProps<TData> {
|
|
19
|
+
columns: ColumnDef<TData, unknown>[];
|
|
20
|
+
data: TData[];
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
sorting?: SortingState;
|
|
23
|
+
onSortingChange?: (sorting: SortingState) => void;
|
|
24
|
+
pagination?: PaginationState;
|
|
25
|
+
onPaginationChange?: (pagination: PaginationState) => void;
|
|
26
|
+
pageCount?: number;
|
|
27
|
+
emptyIcon?: ReactNode;
|
|
28
|
+
emptyTitle?: string;
|
|
29
|
+
emptyDescription?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AppTable<TData>({
|
|
34
|
+
columns,
|
|
35
|
+
data,
|
|
36
|
+
loading = false,
|
|
37
|
+
sorting,
|
|
38
|
+
onSortingChange,
|
|
39
|
+
pagination,
|
|
40
|
+
onPaginationChange,
|
|
41
|
+
pageCount,
|
|
42
|
+
emptyIcon,
|
|
43
|
+
emptyTitle = "No results",
|
|
44
|
+
emptyDescription,
|
|
45
|
+
className,
|
|
46
|
+
}: AppTableProps<TData>) {
|
|
47
|
+
const table = useReactTable({
|
|
48
|
+
data,
|
|
49
|
+
columns,
|
|
50
|
+
pageCount,
|
|
51
|
+
state: {
|
|
52
|
+
sorting,
|
|
53
|
+
pagination,
|
|
54
|
+
},
|
|
55
|
+
onSortingChange,
|
|
56
|
+
onPaginationChange,
|
|
57
|
+
getCoreRowModel: getCoreRowModel(),
|
|
58
|
+
getSortedRowModel: getSortedRowModel(),
|
|
59
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
60
|
+
manualPagination: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (loading) {
|
|
64
|
+
return (
|
|
65
|
+
<div data-slot="app-table" className={cn("rounded-md border", className)}>
|
|
66
|
+
<div className="space-y-2 p-4">
|
|
67
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
68
|
+
<AppSkeleton key={i} className="h-12 w-full" />
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (data.length === 0) {
|
|
76
|
+
return (
|
|
77
|
+
<div data-slot="app-table" className={cn("rounded-md border", className)}>
|
|
78
|
+
<AppEmptyState
|
|
79
|
+
icon={emptyIcon}
|
|
80
|
+
title={emptyTitle}
|
|
81
|
+
description={emptyDescription}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div data-slot="app-table" className={cn("rounded-md border", className)}>
|
|
89
|
+
<div className="overflow-auto">
|
|
90
|
+
<table className="w-full caption-bottom text-sm">
|
|
91
|
+
<thead className="border-b bg-muted/50">
|
|
92
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
93
|
+
<tr key={headerGroup.id}>
|
|
94
|
+
{headerGroup.headers.map((header) => (
|
|
95
|
+
<th
|
|
96
|
+
key={header.id}
|
|
97
|
+
className="h-10 px-4 text-left font-medium text-secondary"
|
|
98
|
+
>
|
|
99
|
+
{header.isPlaceholder
|
|
100
|
+
? null
|
|
101
|
+
: flexRender(
|
|
102
|
+
header.column.columnDef.header,
|
|
103
|
+
header.getContext()
|
|
104
|
+
)}
|
|
105
|
+
</th>
|
|
106
|
+
))}
|
|
107
|
+
</tr>
|
|
108
|
+
))}
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody>
|
|
111
|
+
{table.getRowModel().rows.map((row) => (
|
|
112
|
+
<tr
|
|
113
|
+
key={row.id}
|
|
114
|
+
className="border-b transition-colors hover:bg-muted/50"
|
|
115
|
+
>
|
|
116
|
+
{row.getVisibleCells().map((cell) => (
|
|
117
|
+
<td key={cell.id} className="p-4">
|
|
118
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
119
|
+
</td>
|
|
120
|
+
))}
|
|
121
|
+
</tr>
|
|
122
|
+
))}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AppButton, buttonVariants, type AppButtonProps } from "./app-button";
|
|
2
|
+
export { AppEmptyState, type AppEmptyStateProps } from "./app-empty-state";
|
|
3
|
+
export { AppSkeleton, type AppSkeletonProps } from "./app-skeleton";
|
|
4
|
+
export { AppTable, type AppTableProps } from "./app-table";
|
|
5
|
+
export { AppListToolbar, type AppListToolbarProps } from "./app-list-toolbar";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
4
|
+
import { useCallback, useTransition } from "react";
|
|
5
|
+
|
|
6
|
+
export interface ListState {
|
|
7
|
+
search: string;
|
|
8
|
+
page: number;
|
|
9
|
+
limit: number;
|
|
10
|
+
sort?: string;
|
|
11
|
+
order?: "asc" | "desc";
|
|
12
|
+
[key: string]: string | number | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useListState(defaults: Partial<ListState> = {}) {
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const searchParams = useSearchParams();
|
|
18
|
+
const [isPending, startTransition] = useTransition();
|
|
19
|
+
|
|
20
|
+
const state: ListState = {
|
|
21
|
+
search: searchParams.get("search") ?? defaults.search ?? "",
|
|
22
|
+
page: Number(searchParams.get("page")) || defaults.page || 1,
|
|
23
|
+
limit: Number(searchParams.get("limit")) || defaults.limit || 20,
|
|
24
|
+
sort: searchParams.get("sort") ?? defaults.sort,
|
|
25
|
+
order: (searchParams.get("order") as "asc" | "desc") ?? defaults.order,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const setSearchParams = useCallback(
|
|
29
|
+
(updates: Partial<ListState>) => {
|
|
30
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
31
|
+
Object.entries(updates).forEach(([key, value]) => {
|
|
32
|
+
if (value === undefined || value === null || value === "") {
|
|
33
|
+
params.delete(key);
|
|
34
|
+
} else {
|
|
35
|
+
params.set(key, String(value));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (updates.search !== undefined) {
|
|
39
|
+
params.set("page", "1");
|
|
40
|
+
}
|
|
41
|
+
startTransition(() => {
|
|
42
|
+
router.push(`?${params.toString()}`, { scroll: false });
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
[router, searchParams, startTransition]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const setSearch = useCallback(
|
|
49
|
+
(search: string) => setSearchParams({ search }),
|
|
50
|
+
[setSearchParams]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const setPage = useCallback(
|
|
54
|
+
(page: number) => setSearchParams({ page }),
|
|
55
|
+
[setSearchParams]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const setSort = useCallback(
|
|
59
|
+
(sort: string, order: "asc" | "desc" = "asc") =>
|
|
60
|
+
setSearchParams({ sort, order }),
|
|
61
|
+
[setSearchParams]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
state,
|
|
66
|
+
setSearch,
|
|
67
|
+
setPage,
|
|
68
|
+
setSort,
|
|
69
|
+
setParams: setSearchParams,
|
|
70
|
+
isPending,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from "next/navigation";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
|
|
6
|
+
export function useUrlTab(defaultValue: string = "overview") {
|
|
7
|
+
const searchParams = useSearchParams();
|
|
8
|
+
const tab = searchParams.get("tab") ?? defaultValue;
|
|
9
|
+
|
|
10
|
+
const setTab = useCallback(
|
|
11
|
+
(value: string) => {
|
|
12
|
+
const url = new URL(window.location.href);
|
|
13
|
+
if (value === defaultValue) {
|
|
14
|
+
url.searchParams.delete("tab");
|
|
15
|
+
} else {
|
|
16
|
+
url.searchParams.set("tab", value);
|
|
17
|
+
}
|
|
18
|
+
window.history.replaceState({}, "", url.toString());
|
|
19
|
+
window.dispatchEvent(new Event("popstate"));
|
|
20
|
+
},
|
|
21
|
+
[defaultValue]
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return { tab, setTab };
|
|
25
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const BASE_URL =
|
|
2
|
+
typeof window !== "undefined"
|
|
3
|
+
? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"
|
|
4
|
+
: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
5
|
+
|
|
6
|
+
let _onUnauthorized: (() => void) | null = null;
|
|
7
|
+
|
|
8
|
+
export function onUnauthorized(fn: () => void) {
|
|
9
|
+
_onUnauthorized = fn;
|
|
10
|
+
return () => {
|
|
11
|
+
_onUnauthorized = null;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ApiError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
public code: string,
|
|
18
|
+
message: string,
|
|
19
|
+
public status: number,
|
|
20
|
+
public details?: unknown
|
|
21
|
+
) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ApiError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PaginatedResponse<T> {
|
|
28
|
+
data: T[];
|
|
29
|
+
pagination: {
|
|
30
|
+
page: number;
|
|
31
|
+
limit: number;
|
|
32
|
+
total: number;
|
|
33
|
+
totalPages: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getToken(): Promise<string | null> {
|
|
38
|
+
try {
|
|
39
|
+
const mod = await import("next-auth/react");
|
|
40
|
+
const session = await mod.getSession();
|
|
41
|
+
return (session as { user?: { accessToken?: string } })?.user
|
|
42
|
+
?.accessToken ?? null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function apiFetch<T>(
|
|
49
|
+
path: string,
|
|
50
|
+
opts: RequestInit = {}
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
const token = await getToken();
|
|
53
|
+
const headers: Record<string, string> = {
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
...(opts.headers as Record<string, string>),
|
|
56
|
+
};
|
|
57
|
+
if (token) {
|
|
58
|
+
headers.Authorization = `Bearer ${token}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
62
|
+
...opts,
|
|
63
|
+
headers,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (res.status === 401) {
|
|
67
|
+
_onUnauthorized?.();
|
|
68
|
+
throw new ApiError("UNAUTHORIZED", "Unauthorized", 401);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = await res.json().catch(() => ({}));
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new ApiError(
|
|
75
|
+
body?.error?.code ?? "UNKNOWN_ERROR",
|
|
76
|
+
body?.error?.message ?? "An error occurred",
|
|
77
|
+
res.status,
|
|
78
|
+
body?.error?.details
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (body?.data ?? body) as T;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function apiFetchEnvelope<T>(
|
|
86
|
+
path: string,
|
|
87
|
+
opts: RequestInit = {}
|
|
88
|
+
): Promise<{ data: T; pagination?: PaginatedResponse<T>["pagination"] }> {
|
|
89
|
+
const token = await getToken();
|
|
90
|
+
const headers: Record<string, string> = {
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
...(opts.headers as Record<string, string>),
|
|
93
|
+
};
|
|
94
|
+
if (token) {
|
|
95
|
+
headers.Authorization = `Bearer ${token}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
99
|
+
...opts,
|
|
100
|
+
headers,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (res.status === 401) {
|
|
104
|
+
_onUnauthorized?.();
|
|
105
|
+
throw new ApiError("UNAUTHORIZED", "Unauthorized", 401);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const body = await res.json().catch(() => ({}));
|
|
109
|
+
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new ApiError(
|
|
112
|
+
body?.error?.code ?? "UNKNOWN_ERROR",
|
|
113
|
+
body?.error?.message ?? "An error occurred",
|
|
114
|
+
res.status,
|
|
115
|
+
body?.error?.details
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
data: (body?.data ?? body) as T,
|
|
121
|
+
pagination: body?.pagination,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider } from "next-themes";
|
|
4
|
+
import { SessionProvider } from "next-auth/react";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
9
|
+
const [queryClient] = useState(
|
|
10
|
+
() =>
|
|
11
|
+
new QueryClient({
|
|
12
|
+
defaultOptions: {
|
|
13
|
+
queries: {
|
|
14
|
+
retry: 1,
|
|
15
|
+
staleTime: 30_000,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<ThemeProvider
|
|
23
|
+
attribute="class"
|
|
24
|
+
defaultTheme="light"
|
|
25
|
+
enableSystem
|
|
26
|
+
disableTransitionOnChange
|
|
27
|
+
>
|
|
28
|
+
<SessionProvider>
|
|
29
|
+
<QueryClientProvider client={queryClient}>
|
|
30
|
+
{children}
|
|
31
|
+
</QueryClientProvider>
|
|
32
|
+
</SessionProvider>
|
|
33
|
+
</ThemeProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
export interface ListQuery {
|
|
3
|
+
page?: number;
|
|
4
|
+
limit?: number;
|
|
5
|
+
search?: string;
|
|
6
|
+
sort?: string;
|
|
7
|
+
order?: "asc" | "desc";
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const qk = {
|
|
12
|
+
all: ["api"] as const,
|
|
13
|
+
users: () => [...qk.all, "users"] as const,
|
|
14
|
+
user: (id: string) => [...qk.users(), id] as const,
|
|
15
|
+
projects: () => [...qk.all, "projects"] as const,
|
|
16
|
+
project: (id: string) => [...qk.projects(), id] as const,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function qkList(base: readonly unknown[], query?: ListQuery) {
|
|
20
|
+
return [...base, { ...(query ?? {}) }] as const;
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const emailSchema = z.string().email("Invalid email address");
|
|
4
|
+
|
|
5
|
+
export const passwordSchema = z
|
|
6
|
+
.string()
|
|
7
|
+
.min(8, "Password must be at least 8 characters")
|
|
8
|
+
.max(128, "Password must be at most 128 characters");
|
|
9
|
+
|
|
10
|
+
export const nameSchema = z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1, "Name is required")
|
|
13
|
+
.max(100, "Name must be at most 100 characters");
|
|
14
|
+
|
|
15
|
+
export const uuidSchema = z.string().uuid("Invalid UUID");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import type { ColumnDef } from "@tanstack/react-table";
|
|
6
|
+
import { Add, DocumentText1, Refresh, Trash } from "iconsax-react";
|
|
7
|
+
import { AppButton, AppListToolbar, AppTable } from "@/components/shared";
|
|
8
|
+
import { useListState } from "@/hooks/use-list-state";
|
|
9
|
+
import { apiFetchEnvelope } from "@/lib/api/client";
|
|
10
|
+
import { qk, qkList } from "@/lib/query-keys";
|
|
11
|
+
|
|
12
|
+
interface ExampleItem {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function ExampleListView() {
|
|
19
|
+
const { state, setSearch, isPending } = useListState();
|
|
20
|
+
const queryClient = useQueryClient();
|
|
21
|
+
|
|
22
|
+
const query = useQuery({
|
|
23
|
+
queryKey: qkList(qk.all, state),
|
|
24
|
+
queryFn: () =>
|
|
25
|
+
apiFetchEnvelope<{ items: ExampleItem[]; pagination?: { total: number; page: number; totalPages: number } }>(
|
|
26
|
+
`/examples?page=${state.page}&limit=${state.limit}&search=${encodeURIComponent(state.search)}`
|
|
27
|
+
),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const deleteMutation = useMutation({
|
|
31
|
+
mutationFn: (id: string) =>
|
|
32
|
+
apiFetchEnvelope(`/examples/${id}`, { method: "DELETE" }),
|
|
33
|
+
onSuccess: () => void query.invalidateQueries(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function handleDelete(id: string) {
|
|
37
|
+
deleteMutation.mutate(id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const columns = useMemo<ColumnDef<ExampleItem, unknown>[]>(
|
|
41
|
+
() => [
|
|
42
|
+
{ accessorKey: "name", header: "Name" },
|
|
43
|
+
{
|
|
44
|
+
accessorKey: "createdAt",
|
|
45
|
+
header: "Created",
|
|
46
|
+
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "actions",
|
|
50
|
+
header: "",
|
|
51
|
+
cell: ({ row }) => (
|
|
52
|
+
<AppButton variant="ghost" onClick={() => handleDelete(row.original.id)}>
|
|
53
|
+
<Trash variant="Outline" color="currentColor" className="h-4 w-4" />
|
|
54
|
+
</AppButton>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
[deleteMutation.isPending]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="space-y-6">
|
|
63
|
+
<div className="flex items-center justify-between">
|
|
64
|
+
<h1 className="text-2xl font-bold">Examples</h1>
|
|
65
|
+
<AppButton>
|
|
66
|
+
<Add variant="Outline" color="currentColor" className="h-4 w-4" />
|
|
67
|
+
Create
|
|
68
|
+
</AppButton>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<AppListToolbar
|
|
72
|
+
search={state.search}
|
|
73
|
+
onSearchChange={setSearch}
|
|
74
|
+
searchPlaceholder="Search examples..."
|
|
75
|
+
resultCount={query.data?.pagination?.total}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
{query.isError ? (
|
|
79
|
+
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-8 text-center">
|
|
80
|
+
<p className="text-sm text-muted-foreground">Could not load examples.</p>
|
|
81
|
+
<AppButton variant="outline" className="mt-3" onClick={() => void query.refetch()}>
|
|
82
|
+
<Refresh variant="Outline" color="currentColor" className="h-4 w-4" />
|
|
83
|
+
Retry
|
|
84
|
+
</AppButton>
|
|
85
|
+
</div>
|
|
86
|
+
) : (
|
|
87
|
+
<AppTable
|
|
88
|
+
columns={columns}
|
|
89
|
+
data={query.data?.items ?? []}
|
|
90
|
+
loading={query.isLoading || isPending}
|
|
91
|
+
emptyIcon={<DocumentText1 variant="TwoTone" color="currentColor" />}
|
|
92
|
+
emptyTitle="No examples"
|
|
93
|
+
emptyDescription="Create your first example to get started."
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./src/*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|