@kyro-cms/admin 0.1.2

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.
Files changed (102) hide show
  1. package/.astro/content.d.ts +154 -0
  2. package/.astro/settings.json +5 -0
  3. package/.astro/types.d.ts +2 -0
  4. package/astro.config.mjs +28 -0
  5. package/bun.lock +1374 -0
  6. package/dist/client/_astro/AdminLayout.DkDpng53.css +1 -0
  7. package/dist/client/_astro/AutoForm.3eJCmCJp.js +1 -0
  8. package/dist/client/_astro/client.DyczpTbx.js +9 -0
  9. package/dist/client/_astro/index.B02hbnpo.js +1 -0
  10. package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
  11. package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
  12. package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
  13. package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
  14. package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
  15. package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +26 -0
  16. package/dist/server/chunks/_id__BzI_o0qT.mjs +50 -0
  17. package/dist/server/chunks/_id__Cd-jOuY3.mjs +238 -0
  18. package/dist/server/chunks/_id__DvbD--iR.mjs +992 -0
  19. package/dist/server/chunks/_id__vpVaEo16.mjs +128 -0
  20. package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +7 -0
  21. package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +4 -0
  22. package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +37 -0
  23. package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +74 -0
  24. package/dist/server/chunks/config_CPXslElD.mjs +4221 -0
  25. package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +89 -0
  26. package/dist/server/chunks/index_CVqOkerS.mjs +2960 -0
  27. package/dist/server/chunks/index_CX8SQ4BF.mjs +55 -0
  28. package/dist/server/chunks/index_CYofDU51.mjs +58 -0
  29. package/dist/server/chunks/index_DdNRhuaM.mjs +55 -0
  30. package/dist/server/chunks/index_DupPvtIF.mjs +42 -0
  31. package/dist/server/chunks/index_YTS_M-B9.mjs +263 -0
  32. package/dist/server/chunks/index_YeCzuVps.mjs +53 -0
  33. package/dist/server/chunks/login_DLyqMRO8.mjs +93 -0
  34. package/dist/server/chunks/logout_CSbt5wea.mjs +50 -0
  35. package/dist/server/chunks/me_C04jlYhH.mjs +41 -0
  36. package/dist/server/chunks/new_BbQ9b55M.mjs +92 -0
  37. package/dist/server/chunks/node_9bvTewss.mjs +1014 -0
  38. package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +3 -0
  39. package/dist/server/chunks/sequence_9cl7AJy-.mjs +2503 -0
  40. package/dist/server/chunks/server_peBx9VXG.mjs +8117 -0
  41. package/dist/server/chunks/sharp_pmJ7nHES.mjs +142 -0
  42. package/dist/server/chunks/users_Dzddy_YR.mjs +137 -0
  43. package/dist/server/entry.mjs +5 -0
  44. package/dist/server/virtual_astro_middleware.mjs +48 -0
  45. package/package.json +33 -0
  46. package/public/fonts/Serotiva-Black.woff2 +0 -0
  47. package/public/fonts/Serotiva-Bold.woff2 +0 -0
  48. package/public/fonts/Serotiva-Medium.woff2 +0 -0
  49. package/public/fonts/Serotiva-Regular.woff2 +0 -0
  50. package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
  51. package/src/collections/auth/index.ts +155 -0
  52. package/src/components/ActionBar.tsx +215 -0
  53. package/src/components/Admin.tsx +214 -0
  54. package/src/components/AutoForm.tsx +1123 -0
  55. package/src/components/BulkActionsBar.tsx +80 -0
  56. package/src/components/CreateView.tsx +99 -0
  57. package/src/components/DetailView.tsx +329 -0
  58. package/src/components/Icons.tsx +23 -0
  59. package/src/components/ListView.tsx +192 -0
  60. package/src/components/StatusBadge.tsx +76 -0
  61. package/src/components/ThemeProvider.tsx +155 -0
  62. package/src/components/VersionHistoryPanel.tsx +205 -0
  63. package/src/components/fields/CheckboxField.tsx +37 -0
  64. package/src/components/fields/DateField.tsx +42 -0
  65. package/src/components/fields/NumberField.tsx +44 -0
  66. package/src/components/fields/RelationshipField.tsx +87 -0
  67. package/src/components/fields/SelectField.tsx +56 -0
  68. package/src/components/fields/TextField.tsx +49 -0
  69. package/src/components/index.ts +30 -0
  70. package/src/components/layout/Breadcrumbs.tsx +36 -0
  71. package/src/components/layout/Header.tsx +37 -0
  72. package/src/components/layout/Layout.tsx +25 -0
  73. package/src/components/layout/Sidebar.tsx +462 -0
  74. package/src/components/ui/Badge.tsx +14 -0
  75. package/src/components/ui/Button.tsx +41 -0
  76. package/src/components/ui/Dropdown.tsx +82 -0
  77. package/src/components/ui/Modal.tsx +135 -0
  78. package/src/components/ui/SlidePanel.tsx +73 -0
  79. package/src/components/ui/Spinner.tsx +24 -0
  80. package/src/components/ui/Toast.tsx +78 -0
  81. package/src/layouts/AdminLayout.astro +197 -0
  82. package/src/lib/config.ts +68 -0
  83. package/src/lib/dataStore.ts +111 -0
  84. package/src/middleware.ts +48 -0
  85. package/src/pages/[collection]/[id].astro +176 -0
  86. package/src/pages/[collection]/index.astro +180 -0
  87. package/src/pages/api/[collection]/[id].ts +258 -0
  88. package/src/pages/api/[collection]/index.ts +289 -0
  89. package/src/pages/api/auth/[id].ts +142 -0
  90. package/src/pages/api/auth/audit-logs.ts +80 -0
  91. package/src/pages/api/auth/login.ts +101 -0
  92. package/src/pages/api/auth/logout.ts +48 -0
  93. package/src/pages/api/auth/me.ts +36 -0
  94. package/src/pages/api/auth/users.ts +150 -0
  95. package/src/pages/audit/index.astro +110 -0
  96. package/src/pages/index.astro +225 -0
  97. package/src/pages/roles/index.astro +114 -0
  98. package/src/pages/users/[id].astro +174 -0
  99. package/src/pages/users/index.astro +142 -0
  100. package/src/pages/users/new.astro +91 -0
  101. package/src/styles/main.css +1449 -0
  102. package/tsconfig.json +12 -0
@@ -0,0 +1,192 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { CollectionConfig, KyroConfig } from "@kyro-cms/core";
3
+ import { Spinner } from "./ui/Spinner";
4
+
5
+ interface ListViewProps {
6
+ config: KyroConfig;
7
+ collection: CollectionConfig;
8
+ onCreate: () => void;
9
+ onEdit: (id: string) => void;
10
+ }
11
+
12
+ export function ListView({
13
+ config,
14
+ collection,
15
+ onCreate,
16
+ onEdit,
17
+ }: ListViewProps) {
18
+ const [docs, setDocs] = useState<any[]>([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [page, setPage] = useState(1);
21
+ const [totalPages, setTotalPages] = useState(1);
22
+ const [limit] = useState(25);
23
+
24
+ const label = collection.label || collection.slug;
25
+
26
+ useEffect(() => {
27
+ loadDocs();
28
+ }, [page]);
29
+
30
+ const loadDocs = async () => {
31
+ try {
32
+ setLoading(true);
33
+ const response = await fetch(
34
+ `/api/${collection.slug}?page=${page}&limit=${limit}`,
35
+ );
36
+ if (!response.ok) throw new Error("Failed to load");
37
+ const result = await response.json();
38
+ setDocs(result.docs || []);
39
+ setTotalPages(result.totalPages || 1);
40
+ } catch (error) {
41
+ console.error("Failed to load docs:", error);
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ };
46
+
47
+ const handleDelete = async (id: string) => {
48
+ if (!confirm("Delete this document?")) return;
49
+ try {
50
+ const response = await fetch(`/api/${collection.slug}/${id}`, {
51
+ method: "DELETE",
52
+ });
53
+ if (response.ok) {
54
+ setDocs((prev) => prev.filter((d) => d.id !== id));
55
+ }
56
+ } catch (error) {
57
+ console.error("Failed to delete:", error);
58
+ }
59
+ };
60
+
61
+ const columns =
62
+ collection.admin?.defaultColumns ||
63
+ Object.keys(collection.fields || {}).slice(0, 4);
64
+
65
+ return (
66
+ <div className="kyro-list">
67
+ <div className="kyro-list-header">
68
+ <h2 className="kyro-list-title">{label}</h2>
69
+ <button
70
+ className="kyro-btn kyro-btn-primary kyro-btn-md"
71
+ onClick={onCreate}
72
+ >
73
+ <svg
74
+ width="16"
75
+ height="16"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="2"
80
+ >
81
+ <path d="M12 5v14M5 12h14" />
82
+ </svg>
83
+ Create {label}
84
+ </button>
85
+ </div>
86
+
87
+ {loading ? (
88
+ <div className="kyro-loading">
89
+ <Spinner />
90
+ </div>
91
+ ) : docs.length === 0 ? (
92
+ <div className="kyro-card">
93
+ <div className="kyro-empty">
94
+ <svg
95
+ className="kyro-empty-icon"
96
+ width="40"
97
+ height="40"
98
+ viewBox="0 0 24 24"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ strokeWidth="1.5"
102
+ >
103
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
104
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
105
+ </svg>
106
+ <p className="kyro-empty-title">No {label.toLowerCase()} yet</p>
107
+ <p className="kyro-empty-text">
108
+ Get started by creating your first one.
109
+ </p>
110
+ <button
111
+ className="kyro-btn kyro-btn-primary kyro-btn-md"
112
+ style={{ marginTop: 16 }}
113
+ onClick={onCreate}
114
+ >
115
+ Create {label}
116
+ </button>
117
+ </div>
118
+ </div>
119
+ ) : (
120
+ <div className="kyro-card">
121
+ <table className="kyro-table">
122
+ <thead>
123
+ <tr>
124
+ {columns.map((col) => (
125
+ <th key={col}>{col}</th>
126
+ ))}
127
+ <th style={{ width: 100 }}>Actions</th>
128
+ </tr>
129
+ </thead>
130
+ <tbody>
131
+ {docs.map((doc) => (
132
+ <tr key={doc.id}>
133
+ {columns.map((col) => (
134
+ <td key={col}>{formatValue(doc[col])}</td>
135
+ ))}
136
+ <td>
137
+ <div className="kyro-table-actions">
138
+ <button
139
+ className="kyro-table-action"
140
+ onClick={() => onEdit(doc.id)}
141
+ title="Edit"
142
+ >
143
+ <svg
144
+ width="16"
145
+ height="16"
146
+ viewBox="0 0 24 24"
147
+ fill="none"
148
+ stroke="currentColor"
149
+ strokeWidth="2"
150
+ >
151
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
152
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
153
+ </svg>
154
+ </button>
155
+ <button
156
+ className="kyro-table-action danger"
157
+ onClick={() => handleDelete(doc.id)}
158
+ title="Delete"
159
+ >
160
+ <svg
161
+ width="16"
162
+ height="16"
163
+ viewBox="0 0 24 24"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ strokeWidth="2"
167
+ >
168
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
169
+ </svg>
170
+ </button>
171
+ </div>
172
+ </td>
173
+ </tr>
174
+ ))}
175
+ </tbody>
176
+ </table>
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function formatValue(value: any): string {
184
+ if (value === null || value === undefined) return "—";
185
+ if (typeof value === "boolean") return value ? "Yes" : "No";
186
+ if (typeof value === "object") {
187
+ if (value.title) return value.title;
188
+ if (value.name) return value.name;
189
+ return JSON.stringify(value).slice(0, 50);
190
+ }
191
+ return String(value).slice(0, 50);
192
+ }
@@ -0,0 +1,76 @@
1
+ import React, { type ReactNode } from "react";
2
+
3
+ interface StatusBadgeProps {
4
+ status:
5
+ | "draft"
6
+ | "published"
7
+ | "scheduled"
8
+ | "archived"
9
+ | "active"
10
+ | "inactive"
11
+ | "pending"
12
+ | "completed"
13
+ | "cancelled";
14
+ children?: ReactNode;
15
+ }
16
+
17
+ export function StatusBadge({ status, children }: StatusBadgeProps) {
18
+ const statusConfig: Record<string, { class: string; defaultLabel?: string }> =
19
+ {
20
+ draft: { class: "bg-gray-100 text-gray-600", defaultLabel: "Draft" },
21
+ published: {
22
+ class: "bg-green-100 text-green-700",
23
+ defaultLabel: "Published",
24
+ },
25
+ scheduled: {
26
+ class: "bg-blue-100 text-blue-700",
27
+ defaultLabel: "Scheduled",
28
+ },
29
+ archived: {
30
+ class: "bg-yellow-100 text-yellow-700",
31
+ defaultLabel: "Archived",
32
+ },
33
+ active: { class: "bg-green-100 text-green-700", defaultLabel: "Active" },
34
+ inactive: {
35
+ class: "bg-gray-100 text-gray-600",
36
+ defaultLabel: "Inactive",
37
+ },
38
+ pending: {
39
+ class: "bg-yellow-100 text-yellow-700",
40
+ defaultLabel: "Pending",
41
+ },
42
+ completed: {
43
+ class: "bg-green-100 text-green-700",
44
+ defaultLabel: "Completed",
45
+ },
46
+ cancelled: {
47
+ class: "bg-red-100 text-red-700",
48
+ defaultLabel: "Cancelled",
49
+ },
50
+ };
51
+
52
+ const config = statusConfig[status] || { class: "bg-gray-100 text-gray-600" };
53
+
54
+ return (
55
+ <span
56
+ className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.class}`}
57
+ >
58
+ {children || config.defaultLabel || status}
59
+ </span>
60
+ );
61
+ }
62
+
63
+ interface CountBadgeProps {
64
+ count: number;
65
+ max?: number;
66
+ }
67
+
68
+ export function CountBadge({ count, max = 99 }: CountBadgeProps) {
69
+ if (count === 0) return null;
70
+
71
+ return (
72
+ <span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-medium bg-gray-200 text-gray-700 rounded-full">
73
+ {count > max ? `${max}+` : count}
74
+ </span>
75
+ );
76
+ }
@@ -0,0 +1,155 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useEffect,
6
+ type ReactNode,
7
+ } from "react";
8
+ import {
9
+ defaultLightTheme,
10
+ defaultDarkTheme,
11
+ type ThemeConfig,
12
+ } from "@kyro-cms/core";
13
+
14
+ export type ThemeMode = "light" | "dark" | "system";
15
+
16
+ interface ThemeContextValue {
17
+ mode: ThemeMode;
18
+ theme: ThemeConfig;
19
+ setMode: (mode: ThemeMode) => void;
20
+ setCustomTheme: (theme: ThemeConfig) => void;
21
+ }
22
+
23
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
24
+
25
+ export function useTheme() {
26
+ const context = useContext(ThemeContext);
27
+ if (!context) {
28
+ throw new Error("useTheme must be used within a ThemeProvider");
29
+ }
30
+ return context;
31
+ }
32
+
33
+ interface ThemeProviderProps {
34
+ children: ReactNode;
35
+ defaultMode?: ThemeMode;
36
+ themes?: {
37
+ light?: ThemeConfig;
38
+ dark?: ThemeConfig;
39
+ };
40
+ }
41
+
42
+ export function ThemeProvider({
43
+ children,
44
+ defaultMode = "light",
45
+ themes = {},
46
+ }: ThemeProviderProps) {
47
+ const [mode, setMode] = useState<ThemeMode>(defaultMode);
48
+ const [customTheme, setCustomTheme] = useState<ThemeConfig | null>(null);
49
+
50
+ const lightTheme = themes.light || defaultLightTheme;
51
+ const darkTheme = themes.dark || defaultDarkTheme;
52
+
53
+ const getResolvedTheme = (): ThemeConfig => {
54
+ if (customTheme) return customTheme;
55
+
56
+ if (mode === "system") {
57
+ if (typeof window !== "undefined") {
58
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
59
+ ? darkTheme
60
+ : lightTheme;
61
+ }
62
+ return lightTheme;
63
+ }
64
+
65
+ return mode === "dark" ? darkTheme : lightTheme;
66
+ };
67
+
68
+ const [theme, setTheme] = useState<ThemeConfig>(lightTheme);
69
+
70
+ useEffect(() => {
71
+ const resolved = getResolvedTheme();
72
+ setTheme(resolved);
73
+ applyThemeVariables(resolved);
74
+ }, [mode, customTheme]);
75
+
76
+ useEffect(() => {
77
+ if (mode !== "system") return;
78
+
79
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
80
+ const handler = () => {
81
+ const resolved = getResolvedTheme();
82
+ setTheme(resolved);
83
+ applyThemeVariables(resolved);
84
+ };
85
+
86
+ mediaQuery.addEventListener("change", handler);
87
+ return () => mediaQuery.removeEventListener("change", handler);
88
+ }, [mode, customTheme]);
89
+
90
+ const applyThemeVariables = (config: ThemeConfig) => {
91
+ const root = document.documentElement;
92
+
93
+ if (config.colors) {
94
+ Object.entries(config.colors).forEach(([key, value]) => {
95
+ root.style.setProperty(`--kyro-${key}`, value);
96
+ root.style.setProperty(
97
+ `--kyro-${key}-light`,
98
+ adjustBrightness(value, 0.9),
99
+ );
100
+ root.style.setProperty(
101
+ `--kyro-${key}-dark`,
102
+ adjustBrightness(value, 0.8),
103
+ );
104
+ });
105
+ }
106
+
107
+ if (config.borderRadius) {
108
+ Object.entries(config.borderRadius).forEach(([key, value]) => {
109
+ root.style.setProperty(`--kyro-radius-${key}`, value);
110
+ });
111
+ }
112
+
113
+ if (config.fonts) {
114
+ Object.entries(config.fonts).forEach(([key, value]) => {
115
+ root.style.setProperty(`--kyro-font-${key}`, value);
116
+ });
117
+ }
118
+ };
119
+
120
+ const adjustBrightness = (hex: string, factor: number): string => {
121
+ if (!hex.startsWith("#")) return hex;
122
+
123
+ const r = parseInt(hex.slice(1, 3), 16);
124
+ const g = parseInt(hex.slice(3, 5), 16);
125
+ const b = parseInt(hex.slice(5, 7), 16);
126
+
127
+ const adjust = (c: number) =>
128
+ Math.round(c * factor)
129
+ .toString(16)
130
+ .padStart(2, "0");
131
+
132
+ return `#${adjust(r)}${adjust(g)}${adjust(b)}`;
133
+ };
134
+
135
+ return (
136
+ <ThemeContext.Provider
137
+ value={{
138
+ mode,
139
+ theme,
140
+ setMode,
141
+ setCustomTheme,
142
+ }}
143
+ >
144
+ {children}
145
+ </ThemeContext.Provider>
146
+ );
147
+ }
148
+
149
+ export const LightThemeProvider = (
150
+ props: Omit<ThemeProviderProps, "defaultMode">,
151
+ ) => <ThemeProvider defaultMode="light" {...props} />;
152
+
153
+ export const DarkThemeProvider = (
154
+ props: Omit<ThemeProviderProps, "defaultMode">,
155
+ ) => <ThemeProvider defaultMode="dark" {...props} />;
@@ -0,0 +1,205 @@
1
+ import React from "react";
2
+ import { SlidePanel } from "./ui/SlidePanel";
3
+ import { Button } from "./ui/Button";
4
+ import { Spinner } from "./ui/Spinner";
5
+
6
+ interface Version {
7
+ id: string;
8
+ version: number;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ createdBy?: {
12
+ id: string;
13
+ name: string;
14
+ email?: string;
15
+ };
16
+ status: "draft" | "published";
17
+ changelog?: string;
18
+ }
19
+
20
+ interface VersionHistoryPanelProps {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ versions: Version[];
24
+ currentVersionId?: string;
25
+ onPreview: (version: Version) => void;
26
+ onRestore: (version: Version) => void;
27
+ onCompare?: (v1: Version, v2: Version) => void;
28
+ loading?: boolean;
29
+ }
30
+
31
+ export function VersionHistoryPanel({
32
+ open,
33
+ onClose,
34
+ versions,
35
+ currentVersionId,
36
+ onPreview,
37
+ onRestore,
38
+ onCompare,
39
+ loading = false,
40
+ }: VersionHistoryPanelProps) {
41
+ const formatDate = (dateStr: string) => {
42
+ const date = new Date(dateStr);
43
+ return date.toLocaleDateString("en-US", {
44
+ month: "short",
45
+ day: "numeric",
46
+ year: "numeric",
47
+ hour: "2-digit",
48
+ minute: "2-digit",
49
+ });
50
+ };
51
+
52
+ const formatTimeAgo = (dateStr: string) => {
53
+ const date = new Date(dateStr);
54
+ const now = new Date();
55
+ const diffMs = now.getTime() - date.getTime();
56
+ const diffMins = Math.floor(diffMs / 60000);
57
+ const diffHours = Math.floor(diffMs / 3600000);
58
+ const diffDays = Math.floor(diffMs / 86400000);
59
+
60
+ if (diffMins < 1) return "Just now";
61
+ if (diffMins < 60) return `${diffMins}m ago`;
62
+ if (diffHours < 24) return `${diffHours}h ago`;
63
+ if (diffDays < 7) return `${diffDays}d ago`;
64
+ return formatDate(dateStr);
65
+ };
66
+
67
+ return (
68
+ <SlidePanel
69
+ open={open}
70
+ onClose={onClose}
71
+ title="Version History"
72
+ width="md"
73
+ >
74
+ {loading ? (
75
+ <div className="flex items-center justify-center py-12">
76
+ <Spinner />
77
+ </div>
78
+ ) : versions.length === 0 ? (
79
+ <div className="text-center py-12 text-gray-500">
80
+ <svg
81
+ className="w-12 h-12 mx-auto mb-4 text-gray-300"
82
+ viewBox="0 0 24 24"
83
+ fill="none"
84
+ stroke="currentColor"
85
+ strokeWidth="1.5"
86
+ >
87
+ <circle cx="12" cy="12" r="10" />
88
+ <polyline points="12,6 12,12 16,14" />
89
+ </svg>
90
+ <p>No version history yet</p>
91
+ <p className="text-sm text-gray-400 mt-1">
92
+ Versions are created when you save changes
93
+ </p>
94
+ </div>
95
+ ) : (
96
+ <div className="space-y-1">
97
+ {versions.map((version) => (
98
+ <div
99
+ key={version.id}
100
+ className={`p-3 rounded-lg border transition-colors ${
101
+ version.id === currentVersionId
102
+ ? "border-primary bg-primary-light/30"
103
+ : "border-gray-100 hover:border-gray-200 hover:bg-gray-50"
104
+ }`}
105
+ >
106
+ <div className="flex items-start justify-between">
107
+ <div className="flex-1 min-w-0">
108
+ <div className="flex items-center gap-2 mb-1">
109
+ <span
110
+ className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
111
+ version.status === "published"
112
+ ? "bg-green-100 text-green-700"
113
+ : "bg-gray-100 text-gray-600"
114
+ }`}
115
+ >
116
+ {version.status === "published" ? "Published" : "Draft"}
117
+ </span>
118
+ <span className="text-xs text-gray-400">
119
+ v{version.version}
120
+ </span>
121
+ </div>
122
+ <p className="text-sm text-gray-600 truncate">
123
+ {formatTimeAgo(version.createdAt)}
124
+ </p>
125
+ {version.createdBy && (
126
+ <p className="text-xs text-gray-400 mt-0.5">
127
+ by {version.createdBy.name || version.createdBy.email}
128
+ </p>
129
+ )}
130
+ {version.changelog && (
131
+ <p className="text-xs text-gray-500 mt-1 truncate">
132
+ {version.changelog}
133
+ </p>
134
+ )}
135
+ </div>
136
+ <div className="flex items-center gap-1 ml-2">
137
+ <button
138
+ onClick={() => onPreview(version)}
139
+ className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
140
+ title="Preview this version"
141
+ >
142
+ <svg
143
+ width="14"
144
+ height="14"
145
+ viewBox="0 0 24 24"
146
+ fill="none"
147
+ stroke="currentColor"
148
+ strokeWidth="2"
149
+ >
150
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
151
+ <circle cx="12" cy="12" r="3" />
152
+ </svg>
153
+ </button>
154
+ {onCompare && (
155
+ <button
156
+ onClick={() =>
157
+ onCompare(
158
+ version,
159
+ versions.find((v) => v.id === currentVersionId) ||
160
+ version,
161
+ )
162
+ }
163
+ className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
164
+ title="Compare with current"
165
+ >
166
+ <svg
167
+ width="14"
168
+ height="14"
169
+ viewBox="0 0 24 24"
170
+ fill="none"
171
+ stroke="currentColor"
172
+ strokeWidth="2"
173
+ >
174
+ <path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M3 12h18" />
175
+ </svg>
176
+ </button>
177
+ )}
178
+ {version.id !== currentVersionId && (
179
+ <button
180
+ onClick={() => onRestore(version)}
181
+ className="p-1.5 text-gray-400 hover:text-primary hover:bg-primary-light rounded transition-colors"
182
+ title="Restore this version"
183
+ >
184
+ <svg
185
+ width="14"
186
+ height="14"
187
+ viewBox="0 0 24 24"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ strokeWidth="2"
191
+ >
192
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
193
+ <path d="M3 3v5h5" />
194
+ </svg>
195
+ </button>
196
+ )}
197
+ </div>
198
+ </div>
199
+ </div>
200
+ ))}
201
+ </div>
202
+ )}
203
+ </SlidePanel>
204
+ );
205
+ }
@@ -0,0 +1,37 @@
1
+ import type { CheckboxField as CheckboxFieldType } from '@kyro-cms/core';
2
+
3
+ interface CheckboxFieldComponentProps {
4
+ field: CheckboxFieldType;
5
+ value?: boolean;
6
+ onChange?: (value: boolean) => void;
7
+ error?: string;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ export default function CheckboxField({ field, value = false, onChange, error, disabled }: CheckboxFieldComponentProps) {
12
+ return (
13
+ <div className="space-y-1">
14
+ <label className="flex items-center gap-2 cursor-pointer">
15
+ <input
16
+ type="checkbox"
17
+ checked={value}
18
+ onChange={(e) => onChange?.(e.target.checked)}
19
+ disabled={disabled || field.admin?.readOnly}
20
+ className={`w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 ${
21
+ disabled || field.admin?.readOnly ? 'opacity-50' : ''
22
+ }`}
23
+ />
24
+ <span className="text-sm font-medium text-gray-700">
25
+ {field.label || field.name}
26
+ {field.required && <span className="text-red-500 ml-1">*</span>}
27
+ </span>
28
+ </label>
29
+ {field.admin?.description && !error && (
30
+ <p className="text-xs text-gray-500 ml-6">{field.admin.description}</p>
31
+ )}
32
+ {error && (
33
+ <p className="text-xs text-red-600 ml-6">{error}</p>
34
+ )}
35
+ </div>
36
+ );
37
+ }