@kyro-cms/admin 0.11.6 → 0.12.1

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 (32) hide show
  1. package/dist/index.cjs +25 -25
  2. package/dist/index.d.cts +4 -2
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +20 -20
  5. package/package.json +1 -1
  6. package/src/components/ActionBar.tsx +35 -5
  7. package/src/components/AutoForm.tsx +97 -40
  8. package/src/components/DetailView.tsx +41 -0
  9. package/src/components/FieldRenderer.tsx +11 -0
  10. package/src/components/GraphQLPlayground.tsx +259 -12
  11. package/src/components/MediaGallery.tsx +65 -51
  12. package/src/components/WebhookManager.tsx +338 -188
  13. package/src/components/autoform/AutoFormHeader.tsx +19 -9
  14. package/src/components/autoform/ErrorBoundary.tsx +93 -0
  15. package/src/components/blocks/HeadingSubheadingBlock.tsx +2 -2
  16. package/src/components/blocks/HeroBlock.tsx +2 -2
  17. package/src/components/fields/BlocksField.tsx +69 -17
  18. package/src/components/fields/HeadingSubheadingField.tsx +2 -2
  19. package/src/components/fields/HeroField.tsx +4 -4
  20. package/src/components/fields/IconField.tsx +79 -0
  21. package/src/components/fields/RelationshipField.tsx +2 -2
  22. package/src/components/fields/UploadField.tsx +7 -5
  23. package/src/components/fields/extensions/blocksStore.ts +1 -1
  24. package/src/components/fields/index.ts +1 -0
  25. package/src/components/ui/BlockDrawer.tsx +39 -0
  26. package/src/components/ui/IconPickerModal.tsx +80 -0
  27. package/src/components/ui/ImageFocalEditor.tsx +112 -71
  28. package/src/components/ui/Modal.tsx +4 -2
  29. package/src/components/ui/icons.tsx +1 -1
  30. package/src/hooks/useAutoFormState.ts +51 -1
  31. package/src/integration.ts +4 -4
  32. package/src/pages/settings/[slug].astro +1 -1
@@ -23,6 +23,7 @@ import {
23
23
  Play,
24
24
  Clock,
25
25
  Terminal,
26
+ Search,
26
27
  } from "./ui/icons";
27
28
 
28
29
  function ChevronDown({ className }: { className?: string }) {
@@ -246,14 +247,42 @@ function generateSkeletonQuery(field: FieldInfo, schema: SchemaInfo, isMutation:
246
247
  const returnTypeName = resolveTypeName(field.type);
247
248
  const returnType = schema.types.find((t) => t.name === returnTypeName);
248
249
 
249
- function collectFields(fields: FieldInfo[], indent: number): string[] {
250
+ function buildTypeMap(): Map<string, TypeInfo> {
251
+ const m = new Map<string, TypeInfo>();
252
+ for (const t of schema.types) m.set(t.name, t);
253
+ return m;
254
+ }
255
+ const localTypeMap = buildTypeMap();
256
+
257
+ function collectFieldsWithUnions(fields: FieldInfo[], indent: number, _typeMap: Map<string, TypeInfo>): string[] {
250
258
  const lines: string[] = [];
251
259
  for (const f of fields) {
252
260
  if (f.isDeprecated) continue;
253
261
  const typeName = resolveTypeName(f.type);
262
+ const returnInfo = _typeMap.get(typeName);
254
263
  const pad = " ".repeat(indent);
255
264
  if (isScalarType(typeName) || typeName === "JSON") {
256
265
  lines.push(`${pad}${f.name}`);
266
+ } else if (returnInfo && (returnInfo.kind === "UNION" || returnInfo.kind === "INTERFACE")) {
267
+ lines.push(`${pad}${f.name} {`);
268
+ lines.push(`${pad} __typename`);
269
+ for (const pt of returnInfo.possibleTypes || []) {
270
+ const ptInfo = _typeMap.get(pt.name);
271
+ if (!ptInfo?.fields) continue;
272
+ lines.push(`${pad} ... on ${pt.name} {`);
273
+ for (const pf of ptInfo.fields) {
274
+ if (pf.isDeprecated) continue;
275
+ const pfTypeName = resolveTypeName(pf.type);
276
+ const pfPad = " ".repeat(indent + 2);
277
+ if (isScalarType(pfTypeName) || pfTypeName === "JSON") {
278
+ lines.push(`${pfPad}${pf.name}`);
279
+ } else {
280
+ lines.push(`${pfPad}${pf.name} { id }`);
281
+ }
282
+ }
283
+ lines.push(`${pad} }`);
284
+ }
285
+ lines.push(`${pad}}`);
257
286
  } else {
258
287
  lines.push(`${pad}${f.name} { id }`);
259
288
  }
@@ -269,12 +298,12 @@ function generateSkeletonQuery(field: FieldInfo, schema: SchemaInfo, isMutation:
269
298
  if (docField && isMutation) {
270
299
  const docTypeName = resolveTypeName(docField.type);
271
300
  const docType = schema.types.find((t) => t.name === docTypeName);
272
- const docLines = collectFields(docType?.fields?.filter((f) => !f.isDeprecated) || [], 1);
301
+ const docLines = collectFieldsWithUnions(docType?.fields?.filter((f) => !f.isDeprecated) || [], 1, localTypeMap);
273
302
  selection = [" doc {", ...docLines, " }"]
274
303
  .concat(messageField ? [" message"] : [])
275
304
  .join("\n");
276
305
  } else {
277
- selection = collectFields(returnType?.fields || [], 1).join("\n");
306
+ selection = collectFieldsWithUnions(returnType?.fields || [], 1, localTypeMap).join("\n");
278
307
  }
279
308
 
280
309
  const args = field.args
@@ -358,6 +387,8 @@ interface TypeInfo {
358
387
  fields?: FieldInfo[];
359
388
  inputFields?: FieldInfo[];
360
389
  enumValues?: { name: string; description?: string; isDeprecated: boolean }[];
390
+ possibleTypes?: { name: string; kind: string }[];
391
+ interfaces?: { name: string; kind: string }[];
361
392
  isDeprecated?: boolean;
362
393
  }
363
394
 
@@ -502,6 +533,8 @@ export function GraphQLPlayground({
502
533
  description
503
534
  isDeprecated
504
535
  }
536
+ possibleTypes { name kind }
537
+ interfaces { name kind }
505
538
  }
506
539
  }
507
540
  }
@@ -524,6 +557,93 @@ export function GraphQLPlayground({
524
557
  if (showDocs && !schema) fetchSchema();
525
558
  }, [showDocs, schema, fetchSchema]);
526
559
 
560
+ const [searchQuery, setSearchQuery] = useState("");
561
+
562
+ const typeMap = useMemo(() => {
563
+ const m = new Map<string, TypeInfo>();
564
+ if (!schema) return m;
565
+ for (const t of schema.types) {
566
+ m.set(t.name, t);
567
+ }
568
+ return m;
569
+ }, [schema]);
570
+
571
+ interface SearchResult {
572
+ typeName: string;
573
+ fieldPath: string;
574
+ typeInfo: TypeInfo;
575
+ fieldInfo?: FieldInfo;
576
+ }
577
+
578
+ const searchResults = useMemo(() => {
579
+ if (!searchQuery.trim() || !schema) return [];
580
+ const q = searchQuery.toLowerCase();
581
+ const results: SearchResult[] = [];
582
+ const seenLabels = new Set<string>();
583
+
584
+ for (const type of schema.types) {
585
+ if (type.name.startsWith("__")) continue;
586
+ const typeMatch = type.name.toLowerCase().includes(q);
587
+
588
+ if (typeMatch && !seenLabels.has(type.name)) {
589
+ seenLabels.add(type.name);
590
+ results.push({ typeName: type.name, fieldPath: type.name, typeInfo: type });
591
+ }
592
+
593
+ if (type.fields) {
594
+ for (const field of type.fields) {
595
+ const fieldMatch = field.name.toLowerCase().includes(q);
596
+ if (fieldMatch) {
597
+ const label = `${type.name} > ${field.name}`;
598
+ if (!seenLabels.has(label)) {
599
+ seenLabels.add(label);
600
+ results.push({ typeName: type.name, fieldPath: label, typeInfo: type, fieldInfo: field });
601
+ }
602
+ }
603
+
604
+ if (q.length >= 2) {
605
+ const rtName = resolveTypeName(field.type);
606
+ const rt = typeMap.get(rtName);
607
+ if (rt?.fields && (fieldMatch || results.some(r => r.fieldInfo === field))) {
608
+ for (const sub of rt.fields) {
609
+ if (sub.name.toLowerCase().includes(q)) {
610
+ const label = `${type.name} > ${field.name} > ${sub.name}`;
611
+ if (!seenLabels.has(label)) {
612
+ seenLabels.add(label);
613
+ results.push({ typeName: type.name, fieldPath: label, typeInfo: type, fieldInfo: field });
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ return results;
624
+ }, [searchQuery, schema, typeMap]);
625
+
626
+ const handleSearchSelect = useCallback((result: SearchResult) => {
627
+ const targetType = result.fieldInfo
628
+ ? typeMap.get(resolveTypeName(result.fieldInfo.type)) || result.typeInfo
629
+ : result.typeInfo;
630
+ setSelectedType(targetType);
631
+ setSearchQuery("");
632
+ }, [typeMap]);
633
+
634
+ function highlightMatch(text: string, query: string) {
635
+ if (!query.trim()) return text;
636
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
637
+ if (idx === -1) return text;
638
+ return (
639
+ <>
640
+ {text.slice(0, idx)}
641
+ <mark className="bg-[var(--kyro-primary)]/20 text-[var(--kyro-primary)] rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
642
+ {text.slice(idx + query.length)}
643
+ </>
644
+ );
645
+ }
646
+
527
647
  const handleRun = useCallback(async () => {
528
648
  setIsLoading(true);
529
649
  setError(null);
@@ -850,9 +970,23 @@ export function GraphQLPlayground({
850
970
  <div className="flex-1 overflow-hidden">
851
971
  {rightTab === "docs" ? (
852
972
  <div className="h-full flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
853
- <div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--kyro-border)]">
854
- <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Schema Explorer</span>
855
- <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
973
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)]">
974
+ <div className="relative flex-1">
975
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--kyro-text-muted)]" />
976
+ <input
977
+ type="text"
978
+ value={searchQuery}
979
+ onChange={(e) => setSearchQuery(e.target.value)}
980
+ placeholder="Search types and fields..."
981
+ className="w-full pl-7 pr-6 py-1 text-[10px] bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-md text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:border-[var(--kyro-primary)]"
982
+ />
983
+ {searchQuery && (
984
+ <button onClick={() => setSearchQuery("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
985
+ <X className="w-3 h-3" />
986
+ </button>
987
+ )}
988
+ </div>
989
+ <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] shrink-0">
856
990
  <X className="w-3.5 h-3.5" />
857
991
  </button>
858
992
  </div>
@@ -863,7 +997,39 @@ export function GraphQLPlayground({
863
997
  </div>
864
998
  ) : schema ? (
865
999
  <div className="space-y-4">
866
- {selectedType ? (
1000
+ {searchQuery ? (
1001
+ <div className="space-y-1">
1002
+ {searchResults.length === 0 ? (
1003
+ <p className="text-[10px] text-[var(--kyro-text-muted)] py-4 text-center">No results for "{searchQuery}"</p>
1004
+ ) : (
1005
+ searchResults.map((r, i) => {
1006
+ const targetType = r.fieldInfo ? typeMap.get(resolveTypeName(r.fieldInfo.type)) || r.typeInfo : r.typeInfo;
1007
+ return (
1008
+ <button
1009
+ key={r.fieldPath + i}
1010
+ onClick={() => handleSearchSelect(r)}
1011
+ className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left group"
1012
+ >
1013
+ <span className="text-[10px] font-mono text-[var(--kyro-text-muted)]">
1014
+ {r.fieldPath.split(" > ").map((part, j) => (
1015
+ <span key={j}>
1016
+ {j > 0 && <span className="mx-1 opacity-40">›</span>}
1017
+ <span className={j === r.fieldPath.split(" > ").length - 1 ? "text-[var(--kyro-text-primary)] font-semibold" : "text-[var(--kyro-text-muted)]"}>
1018
+ {highlightMatch(part, searchQuery)}
1019
+ </span>
1020
+ </span>
1021
+ ))}
1022
+ {targetType && (targetType.kind === "UNION" || targetType.kind === "INTERFACE") && (
1023
+ <span className="ml-2 text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1 py-0.5 rounded">⧉</span>
1024
+ )}
1025
+ </span>
1026
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
1027
+ </button>
1028
+ );
1029
+ })
1030
+ )}
1031
+ </div>
1032
+ ) : selectedType ? (
867
1033
  <div className="space-y-3">
868
1034
  <button
869
1035
  onClick={() => setSelectedType(null)}
@@ -878,6 +1044,46 @@ export function GraphQLPlayground({
878
1044
  <p className="mt-1.5 text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">{selectedType.description}</p>
879
1045
  )}
880
1046
  </div>
1047
+ {selectedType.kind === "UNION" && selectedType.possibleTypes && (
1048
+ <div className="space-y-2">
1049
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Possible Types <span className="text-[8px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1 py-0.5 rounded ml-1">⧉ fragment</span></h4>
1050
+ <div className="space-y-1">
1051
+ {selectedType.possibleTypes.map(pt => (
1052
+ <button
1053
+ key={pt.name}
1054
+ onClick={() => {
1055
+ const t = typeMap.get(pt.name);
1056
+ if (t) setSelectedType(t);
1057
+ }}
1058
+ className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left group"
1059
+ >
1060
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{pt.name}</span>
1061
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
1062
+ </button>
1063
+ ))}
1064
+ </div>
1065
+ </div>
1066
+ )}
1067
+ {selectedType.kind === "INTERFACE" && selectedType.possibleTypes && (
1068
+ <div className="space-y-2">
1069
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Implementing Types <span className="text-[8px] font-mono text-[var(--kyro-text-muted)] bg-[var(--kyro-surface)] px-1 py-0.5 rounded border border-[var(--kyro-border)] ml-1">interface</span></h4>
1070
+ <div className="space-y-1">
1071
+ {selectedType.possibleTypes.map(pt => (
1072
+ <button
1073
+ key={pt.name}
1074
+ onClick={() => {
1075
+ const t = typeMap.get(pt.name);
1076
+ if (t) setSelectedType(t);
1077
+ }}
1078
+ className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left group"
1079
+ >
1080
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{pt.name}</span>
1081
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
1082
+ </button>
1083
+ ))}
1084
+ </div>
1085
+ </div>
1086
+ )}
881
1087
  {selectedType.fields && (
882
1088
  <div className="space-y-2">
883
1089
  <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Fields</h4>
@@ -892,9 +1098,46 @@ export function GraphQLPlayground({
892
1098
  >
893
1099
  <div className="flex items-center justify-between gap-2">
894
1100
  <span className="font-semibold text-[11px] text-[var(--kyro-text-primary)]">{f.name}</span>
895
- <span className="text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1.5 py-0.5 rounded">{renderType(f.type)}</span>
1101
+ <span className="flex items-center gap-1">
1102
+ {(() => {
1103
+ const rtName = resolveTypeName(f.type);
1104
+ const rt = typeMap.get(rtName);
1105
+ const isUnionish = rt && (rt.kind === "UNION" || rt.kind === "INTERFACE");
1106
+ return isUnionish ? (
1107
+ <span className="text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1.5 py-0.5 rounded flex items-center gap-1">
1108
+
1109
+ <span className="text-[8px] opacity-70">fragment</span>
1110
+ </span>
1111
+ ) : null;
1112
+ })()}
1113
+ <span className="text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1.5 py-0.5 rounded">{renderType(f.type)}</span>
1114
+ </span>
896
1115
  </div>
897
1116
  {f.description && <p className="text-[10px] text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
1117
+ {(() => {
1118
+ const rtName = resolveTypeName(f.type);
1119
+ const rt = typeMap.get(rtName);
1120
+ if (rt && (rt.kind === "UNION" || rt.kind === "INTERFACE") && rt.possibleTypes?.length) {
1121
+ return (
1122
+ <div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-primary)]/30 space-y-0.5">
1123
+ <span className="text-[8px] font-semibold text-[var(--kyro-text-muted)] uppercase tracking-wider">Possible Types</span>
1124
+ <div className="flex flex-wrap gap-1 mt-0.5">
1125
+ {rt.possibleTypes.map(pt => {
1126
+ const ptInfo = typeMap.get(pt.name);
1127
+ const hasFragment = ptInfo && (ptInfo.kind === "OBJECT" || ptInfo.kind === "INTERFACE");
1128
+ return (
1129
+ <span key={pt.name} className="text-[9px] font-mono text-[var(--kyro-text-muted)] bg-[var(--kyro-surface)] px-1.5 py-0.5 rounded border border-[var(--kyro-border)]">
1130
+ {pt.name}
1131
+ {hasFragment && <span className="ml-1 text-[8px] text-[var(--kyro-primary)]">⧉</span>}
1132
+ </span>
1133
+ );
1134
+ })}
1135
+ </div>
1136
+ </div>
1137
+ );
1138
+ }
1139
+ return null;
1140
+ })()}
898
1141
  {f.args && f.args.length > 0 && (
899
1142
  <div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-border)] space-y-0.5">
900
1143
  {f.args.map(a => (
@@ -934,14 +1177,18 @@ export function GraphQLPlayground({
934
1177
  </div>
935
1178
  <div className="space-y-1.5">
936
1179
  <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">All Types</h4>
937
- {schema.types.filter(t => !t.name.startsWith("__") && t.kind === "OBJECT").map(t => (
1180
+ {schema.types.filter(t => !t.name.startsWith("__") && (t.kind === "OBJECT" || t.kind === "UNION" || t.kind === "INTERFACE")).map(t => (
938
1181
  <button
939
1182
  key={t.name}
940
1183
  onClick={() => setSelectedType(t)}
941
- className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left"
1184
+ className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left group"
942
1185
  >
943
- <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
944
- <ChevronRight className="w-3 h-3 opacity-30" />
1186
+ <div className="flex items-center gap-2">
1187
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
1188
+ {t.kind === "UNION" && <span className="text-[8px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1 py-0.5 rounded">union</span>}
1189
+ {t.kind === "INTERFACE" && <span className="text-[8px] font-mono text-[var(--kyro-text-muted)] bg-[var(--kyro-surface)] px-1 py-0.5 rounded border border-[var(--kyro-border)]">interface</span>}
1190
+ </div>
1191
+ <ChevronRight className="w-3 h-3 opacity-30 group-hover:opacity-60" />
945
1192
  </button>
946
1193
  ))}
947
1194
  </div>
@@ -70,6 +70,23 @@ function getAbsoluteUrl(relativeUrl: unknown): string {
70
70
  return `${window.location.origin}${sanitized}`;
71
71
  }
72
72
 
73
+ function getCroppedUrl(item: MediaItem, width?: number): string | null {
74
+ if (!item.metadata?.crop) return null;
75
+ const { x, y, width: cw, height: ch } = item.metadata.crop;
76
+ if (!cw || !ch) return null;
77
+ const base = getAbsoluteUrl(item.url);
78
+ if (!base) return null;
79
+ const params = new URLSearchParams({ url: item.url });
80
+ params.set("cx", String(x));
81
+ params.set("cy", String(y));
82
+ params.set("cw", String(cw));
83
+ params.set("ch", String(ch));
84
+ if (width) params.set("w", String(width));
85
+ const resizePath = `/api/media/resize?${params.toString()}`;
86
+ if (typeof window === "undefined") return resizePath;
87
+ return `${window.location.origin}${resizePath}`;
88
+ }
89
+
73
90
  type FilterType =
74
91
  | "all"
75
92
  | "image"
@@ -383,12 +400,12 @@ export function MediaGallery({
383
400
  crop: cropData,
384
401
  hotspot: hotspotData
385
402
  };
386
-
403
+
387
404
  const result = await apiPatch(`/api/media/${panelItem.id}`, { metadata });
388
405
  setItems(prev => prev.map(item => item.id === panelItem.id ? result.doc : item));
389
406
  setPanelItem(result.doc);
390
407
  setShowCrop(false);
391
- toast.success("Focal metadata saved");
408
+ toast.success("Crop & hotspot saved");
392
409
  } catch (err) {
393
410
  console.error("Save failed:", err);
394
411
  toast.error("Failed to save focal metadata");
@@ -417,71 +434,68 @@ export function MediaGallery({
417
434
  })}
418
435
  >
419
436
  {/* Top Bar */}
420
- <div className={`flex flex-col lg:flex-row lg:items-center justify-between gap-6 border-b border-[var(--kyro-border)] backdrop-blur-md sticky top-0 ${pickerMode ? "p-2" : "p-6 rounded-xl surface-tile"}`}>
437
+ <div className={`flex flex-col ${pickerMode ? "gap-2 p-2" : "gap-4 md:gap-6 p-4 md:p-6 rounded-md md:rounded-t-xl surface-tile"} border-b border-[var(--kyro-border)] backdrop-blur-md sticky top-0 z-10`}>
421
438
  {!pickerMode && (
422
- <div className="flex items-center gap-4">
439
+ <div className="flex items-center justify-between w-full">
423
440
  <div>
424
- <h2 className="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
441
+ <h2 className="text-lg md:text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
425
442
  Media Library
426
443
  </h2>
427
- <div className="flex items-center gap-3 mt-1">
444
+ <div className="flex items-center gap-2 mt-0.5">
428
445
  <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50">
429
- {total} Items · {formatFileSize(stats.totalSize)}
446
+ {total} Items <span className="hidden sm:inline">· {formatFileSize(stats.totalSize)}</span>
430
447
  </span>
431
448
  </div>
432
449
  </div>
433
- </div>
434
- )}
435
-
436
- <div className={`flex items-center gap-3 flex-wrap lg:flex-nowrap ${pickerMode ? "w-full" : ""}`}>
437
- <div className="relative group flex-1 min-w-[200px]">
438
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
439
- <input
440
- type="text"
441
- placeholder="Search assets..."
442
- value={search}
443
- onChange={(e) => setSearch(e.target.value)}
444
- className="w-full pl-11 pr-4 py-2.5 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all text-xs font-bold"
445
- />
446
- </div>
447
-
448
- {!pickerMode && (
449
- <>
450
- <div className="flex items-center gap-2">
451
- <div className="flex bg-[var(--kyro-surface-accent)] p-1 rounded-xl border border-[var(--kyro-border)]">
452
- <button
453
- onClick={() => setView("grid")}
454
- className={`p-2 rounded-lg transition-all ${view === "grid" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
455
- >
456
- <Grid className="w-4 h-4" />
457
- </button>
458
- <button
459
- onClick={() => setView("list")}
460
- className={`p-2 rounded-lg transition-all ${view === "list" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
461
- >
462
- <FileIcon className="w-4 h-4" />
463
- </button>
464
- </div>
465
450
 
451
+ <div className="flex items-center gap-2">
452
+ <div className="flex bg-[var(--kyro-surface-accent)] p-1 rounded-xl border border-[var(--kyro-border)] hidden sm:flex">
466
453
  <button
467
- onClick={() => setShowMobileFilters(true)}
468
- className="md:hidden p-2 rounded-xl bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
454
+ onClick={() => setView("grid")}
455
+ className={`p-1.5 md:p-2 rounded-lg transition-all ${view === "grid" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
469
456
  >
470
- <Filter className="w-4 h-4" />
457
+ <Grid className="w-4 h-4" />
458
+ </button>
459
+ <button
460
+ onClick={() => setView("list")}
461
+ className={`p-1.5 md:p-2 rounded-lg transition-all ${view === "list" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
462
+ >
463
+ <FileIcon className="w-4 h-4" />
471
464
  </button>
472
465
  </div>
473
466
 
467
+ <button
468
+ onClick={() => setShowMobileFilters(true)}
469
+ className="md:hidden p-2 rounded-xl bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-colors"
470
+ >
471
+ <Filter className="w-4 h-4" />
472
+ </button>
473
+
474
474
  {canUpload && (
475
475
  <button
476
476
  onClick={() => fileInputRef.current?.click()}
477
- className="flex items-center gap-2 px-6 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs shadow-lg active:scale-95 transition-all"
477
+ className="flex items-center justify-center gap-2 px-3 md:px-6 py-2 md:py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs shadow-lg active:scale-95 transition-all"
478
478
  >
479
- <Maximize2 className="w-4 h-4" />
480
- Upload
479
+ <span className="md:hidden text-lg leading-none">+</span>
480
+ <Maximize2 className="w-4 h-4 hidden md:block" />
481
+ <span className="hidden md:inline">Upload</span>
481
482
  </button>
482
483
  )}
483
- </>
484
- )}
484
+ </div>
485
+ </div>
486
+ )}
487
+
488
+ <div className={`flex items-center w-full`}>
489
+ <div className="relative group flex-1">
490
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
491
+ <input
492
+ type="text"
493
+ placeholder="Search assets..."
494
+ value={search}
495
+ onChange={(e) => setSearch(e.target.value)}
496
+ className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all text-xs font-bold"
497
+ />
498
+ </div>
485
499
  </div>
486
500
  </div>
487
501
 
@@ -610,7 +624,7 @@ export function MediaGallery({
610
624
  >
611
625
  {item.type === "image" ? (
612
626
  <img
613
- src={item.url}
627
+ src={getCroppedUrl(item, 400) || item.url}
614
628
  alt={item.title}
615
629
  className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
616
630
  loading="lazy"
@@ -707,7 +721,7 @@ export function MediaGallery({
707
721
  <div className="w-12 h-12 rounded-xl bg-[var(--kyro-bg)] overflow-hidden border border-[var(--kyro-border)] flex-shrink-0 flex items-center justify-center">
708
722
  {item.type === "image" ? (
709
723
  <img
710
- src={item.url}
724
+ src={getCroppedUrl(item, 96) || item.url}
711
725
  alt=""
712
726
  className="w-full h-full object-cover"
713
727
  />
@@ -962,7 +976,7 @@ export function MediaGallery({
962
976
  <div className="aspect-video w-full rounded-2xl bg-[var(--kyro-bg)] border border-[var(--kyro-border)] overflow-hidden relative group">
963
977
  {panelItem.type === "image" ? (
964
978
  <img
965
- src={getAbsoluteUrl(panelItem.url)}
979
+ src={getCroppedUrl(panelItem) || getAbsoluteUrl(panelItem.url)}
966
980
  alt=""
967
981
  className="w-full h-full object-contain p-4"
968
982
  />
@@ -1152,7 +1166,7 @@ export function MediaGallery({
1152
1166
  <div className="flex-1 w-full flex items-center justify-center p-12">
1153
1167
  {panelItem.type === "image" ? (
1154
1168
  <img
1155
- src={getAbsoluteUrl(panelItem.url)}
1169
+ src={getCroppedUrl(panelItem) || getAbsoluteUrl(panelItem.url)}
1156
1170
  alt=""
1157
1171
  className="max-h-full max-w-full object-contain shadow-2xl rounded-lg animate-in zoom-in-95 duration-500"
1158
1172
  />