@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.
- package/.astro/content.d.ts +154 -0
- package/.astro/settings.json +5 -0
- package/.astro/types.d.ts +2 -0
- package/astro.config.mjs +28 -0
- package/bun.lock +1374 -0
- package/dist/client/_astro/AdminLayout.DkDpng53.css +1 -0
- package/dist/client/_astro/AutoForm.3eJCmCJp.js +1 -0
- package/dist/client/_astro/client.DyczpTbx.js +9 -0
- package/dist/client/_astro/index.B02hbnpo.js +1 -0
- package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
- package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +26 -0
- package/dist/server/chunks/_id__BzI_o0qT.mjs +50 -0
- package/dist/server/chunks/_id__Cd-jOuY3.mjs +238 -0
- package/dist/server/chunks/_id__DvbD--iR.mjs +992 -0
- package/dist/server/chunks/_id__vpVaEo16.mjs +128 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +7 -0
- package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +4 -0
- package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +37 -0
- package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +74 -0
- package/dist/server/chunks/config_CPXslElD.mjs +4221 -0
- package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +89 -0
- package/dist/server/chunks/index_CVqOkerS.mjs +2960 -0
- package/dist/server/chunks/index_CX8SQ4BF.mjs +55 -0
- package/dist/server/chunks/index_CYofDU51.mjs +58 -0
- package/dist/server/chunks/index_DdNRhuaM.mjs +55 -0
- package/dist/server/chunks/index_DupPvtIF.mjs +42 -0
- package/dist/server/chunks/index_YTS_M-B9.mjs +263 -0
- package/dist/server/chunks/index_YeCzuVps.mjs +53 -0
- package/dist/server/chunks/login_DLyqMRO8.mjs +93 -0
- package/dist/server/chunks/logout_CSbt5wea.mjs +50 -0
- package/dist/server/chunks/me_C04jlYhH.mjs +41 -0
- package/dist/server/chunks/new_BbQ9b55M.mjs +92 -0
- package/dist/server/chunks/node_9bvTewss.mjs +1014 -0
- package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +3 -0
- package/dist/server/chunks/sequence_9cl7AJy-.mjs +2503 -0
- package/dist/server/chunks/server_peBx9VXG.mjs +8117 -0
- package/dist/server/chunks/sharp_pmJ7nHES.mjs +142 -0
- package/dist/server/chunks/users_Dzddy_YR.mjs +137 -0
- package/dist/server/entry.mjs +5 -0
- package/dist/server/virtual_astro_middleware.mjs +48 -0
- package/package.json +33 -0
- package/public/fonts/Serotiva-Black.woff2 +0 -0
- package/public/fonts/Serotiva-Bold.woff2 +0 -0
- package/public/fonts/Serotiva-Medium.woff2 +0 -0
- package/public/fonts/Serotiva-Regular.woff2 +0 -0
- package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/src/collections/auth/index.ts +155 -0
- package/src/components/ActionBar.tsx +215 -0
- package/src/components/Admin.tsx +214 -0
- package/src/components/AutoForm.tsx +1123 -0
- package/src/components/BulkActionsBar.tsx +80 -0
- package/src/components/CreateView.tsx +99 -0
- package/src/components/DetailView.tsx +329 -0
- package/src/components/Icons.tsx +23 -0
- package/src/components/ListView.tsx +192 -0
- package/src/components/StatusBadge.tsx +76 -0
- package/src/components/ThemeProvider.tsx +155 -0
- package/src/components/VersionHistoryPanel.tsx +205 -0
- package/src/components/fields/CheckboxField.tsx +37 -0
- package/src/components/fields/DateField.tsx +42 -0
- package/src/components/fields/NumberField.tsx +44 -0
- package/src/components/fields/RelationshipField.tsx +87 -0
- package/src/components/fields/SelectField.tsx +56 -0
- package/src/components/fields/TextField.tsx +49 -0
- package/src/components/index.ts +30 -0
- package/src/components/layout/Breadcrumbs.tsx +36 -0
- package/src/components/layout/Header.tsx +37 -0
- package/src/components/layout/Layout.tsx +25 -0
- package/src/components/layout/Sidebar.tsx +462 -0
- package/src/components/ui/Badge.tsx +14 -0
- package/src/components/ui/Button.tsx +41 -0
- package/src/components/ui/Dropdown.tsx +82 -0
- package/src/components/ui/Modal.tsx +135 -0
- package/src/components/ui/SlidePanel.tsx +73 -0
- package/src/components/ui/Spinner.tsx +24 -0
- package/src/components/ui/Toast.tsx +78 -0
- package/src/layouts/AdminLayout.astro +197 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/dataStore.ts +111 -0
- package/src/middleware.ts +48 -0
- package/src/pages/[collection]/[id].astro +176 -0
- package/src/pages/[collection]/index.astro +180 -0
- package/src/pages/api/[collection]/[id].ts +258 -0
- package/src/pages/api/[collection]/index.ts +289 -0
- package/src/pages/api/auth/[id].ts +142 -0
- package/src/pages/api/auth/audit-logs.ts +80 -0
- package/src/pages/api/auth/login.ts +101 -0
- package/src/pages/api/auth/logout.ts +48 -0
- package/src/pages/api/auth/me.ts +36 -0
- package/src/pages/api/auth/users.ts +150 -0
- package/src/pages/audit/index.astro +110 -0
- package/src/pages/index.astro +225 -0
- package/src/pages/roles/index.astro +114 -0
- package/src/pages/users/[id].astro +174 -0
- package/src/pages/users/index.astro +142 -0
- package/src/pages/users/new.astro +91 -0
- package/src/styles/main.css +1449 -0
- 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
|
+
}
|