@morphika/andami 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/LICENSE +21 -0
- package/README.md +50 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +212 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import type { RegisteredAsset, AssetRegistry, RelinkLogEntry } from "../../../lib/sanity/types";
|
|
5
|
+
import { formatDate, formatBytes } from "../../../lib/format-utils";
|
|
6
|
+
|
|
7
|
+
function getStatusColor(status: string): string {
|
|
8
|
+
switch (status) {
|
|
9
|
+
case "active":
|
|
10
|
+
return "text-green-500";
|
|
11
|
+
case "missing":
|
|
12
|
+
return "text-red-500";
|
|
13
|
+
case "moved":
|
|
14
|
+
return "text-yellow-500";
|
|
15
|
+
case "new":
|
|
16
|
+
return "text-blue-400";
|
|
17
|
+
default:
|
|
18
|
+
return "text-neutral-500";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getStatusLabel(status: string): string {
|
|
23
|
+
switch (status) {
|
|
24
|
+
case "active":
|
|
25
|
+
return "Active";
|
|
26
|
+
case "missing":
|
|
27
|
+
return "Missing";
|
|
28
|
+
case "moved":
|
|
29
|
+
return "Moved";
|
|
30
|
+
case "new":
|
|
31
|
+
return "New";
|
|
32
|
+
default:
|
|
33
|
+
return status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================
|
|
38
|
+
// Page Component
|
|
39
|
+
// ============================================
|
|
40
|
+
|
|
41
|
+
export default function AdminAssetsPage() {
|
|
42
|
+
const [registry, setRegistry] = useState<AssetRegistry | null>(null);
|
|
43
|
+
const [loading, setLoading] = useState(true);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [scanning, setScanning] = useState(false);
|
|
46
|
+
const [healthChecking, setHealthChecking] = useState(false);
|
|
47
|
+
const [healthResult, setHealthResult] = useState<{
|
|
48
|
+
healthy_count: number;
|
|
49
|
+
missing_count: number;
|
|
50
|
+
} | null>(null);
|
|
51
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
52
|
+
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
53
|
+
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
54
|
+
const [selectedAsset, setSelectedAsset] = useState<RegisteredAsset | null>(null);
|
|
55
|
+
const [page, setPage] = useState(0);
|
|
56
|
+
const PAGE_SIZE = 50;
|
|
57
|
+
|
|
58
|
+
// Fetch registry
|
|
59
|
+
const fetchRegistry = useCallback(async () => {
|
|
60
|
+
try {
|
|
61
|
+
setError(null);
|
|
62
|
+
const res = await fetch("/api/admin/assets/registry");
|
|
63
|
+
if (!res.ok) throw new Error("Failed to load registry");
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
setRegistry(data.registry);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err instanceof Error ? err.message : "Failed to load");
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
fetchRegistry();
|
|
75
|
+
}, [fetchRegistry]);
|
|
76
|
+
|
|
77
|
+
// Scan
|
|
78
|
+
const handleScan = async () => {
|
|
79
|
+
setScanning(true);
|
|
80
|
+
setError(null);
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch("/api/admin/assets/scan", { method: "POST" });
|
|
83
|
+
const data = await res.json();
|
|
84
|
+
if (!res.ok) throw new Error(data.error || "Scan failed");
|
|
85
|
+
await fetchRegistry();
|
|
86
|
+
alert(
|
|
87
|
+
`Scan complete: ${data.scanned_count} files found, ${data.new_assets} new, ${data.missing_assets} missing`
|
|
88
|
+
);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
setError(err instanceof Error ? err.message : "Scan failed");
|
|
91
|
+
} finally {
|
|
92
|
+
setScanning(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Health check
|
|
97
|
+
const handleHealthCheck = async () => {
|
|
98
|
+
setHealthChecking(true);
|
|
99
|
+
setHealthResult(null);
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch("/api/admin/assets/health");
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
if (!res.ok) throw new Error(data.error || "Health check failed");
|
|
104
|
+
setHealthResult({
|
|
105
|
+
healthy_count: data.healthy_count,
|
|
106
|
+
missing_count: data.missing_count,
|
|
107
|
+
});
|
|
108
|
+
await fetchRegistry();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setError(err instanceof Error ? err.message : "Health check failed");
|
|
111
|
+
} finally {
|
|
112
|
+
setHealthChecking(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Filter assets
|
|
117
|
+
const filteredAssets = (registry?.assets || []).filter((asset) => {
|
|
118
|
+
if (statusFilter !== "all" && asset.status !== statusFilter) return false;
|
|
119
|
+
if (typeFilter !== "all") {
|
|
120
|
+
const imageExts = ["jpg", "jpeg", "png", "webp", "gif", "svg"];
|
|
121
|
+
const videoExts = ["mp4", "webm", "mov"];
|
|
122
|
+
if (typeFilter === "image" && !imageExts.includes(asset.extension))
|
|
123
|
+
return false;
|
|
124
|
+
if (typeFilter === "video" && !videoExts.includes(asset.extension))
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (searchQuery.trim()) {
|
|
128
|
+
const q = searchQuery.toLowerCase();
|
|
129
|
+
if (
|
|
130
|
+
!asset.filename.toLowerCase().includes(q) &&
|
|
131
|
+
!asset.path.toLowerCase().includes(q)
|
|
132
|
+
)
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Pagination
|
|
139
|
+
const totalPages = Math.ceil(filteredAssets.length / PAGE_SIZE);
|
|
140
|
+
const paginatedAssets = filteredAssets.slice(
|
|
141
|
+
page * PAGE_SIZE,
|
|
142
|
+
(page + 1) * PAGE_SIZE
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Stats
|
|
146
|
+
const stats = {
|
|
147
|
+
total: registry?.assets?.length || 0,
|
|
148
|
+
active: registry?.assets?.filter((a) => a.status === "active").length || 0,
|
|
149
|
+
missing: registry?.assets?.filter((a) => a.status === "missing").length || 0,
|
|
150
|
+
newAssets: registry?.assets?.filter((a) => a.status === "new").length || 0,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (loading) {
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex items-center justify-center py-20">
|
|
156
|
+
<span className="font-mono text-sm text-neutral-500 animate-pulse">
|
|
157
|
+
Loading asset registry...
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="space-y-6">
|
|
165
|
+
{/* Header */}
|
|
166
|
+
<div className="flex items-center justify-between">
|
|
167
|
+
<h1 className="font-mono text-lg uppercase tracking-wider text-neutral-900">
|
|
168
|
+
Assets
|
|
169
|
+
</h1>
|
|
170
|
+
<div className="flex gap-2">
|
|
171
|
+
<button
|
|
172
|
+
onClick={handleHealthCheck}
|
|
173
|
+
disabled={healthChecking}
|
|
174
|
+
className="font-mono text-[11px] px-3 py-1.5 rounded border border-neutral-300 text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors disabled:opacity-50"
|
|
175
|
+
>
|
|
176
|
+
{healthChecking ? "Checking..." : "🏥 Health Check"}
|
|
177
|
+
</button>
|
|
178
|
+
<button
|
|
179
|
+
onClick={handleScan}
|
|
180
|
+
disabled={scanning}
|
|
181
|
+
className="font-mono text-[11px] px-3 py-1.5 rounded bg-[#076bff] text-white hover:bg-[#076bff]/80 transition-colors disabled:opacity-50"
|
|
182
|
+
>
|
|
183
|
+
{scanning ? "Scanning..." : "🔄 Scan Storage"}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Error banner */}
|
|
189
|
+
{error && (
|
|
190
|
+
<div className="p-3 rounded border border-red-200 bg-red-50">
|
|
191
|
+
<p className="font-mono text-xs text-red-700">{error}</p>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Health check result */}
|
|
196
|
+
{healthResult && (
|
|
197
|
+
<div className="p-3 rounded border border-neutral-200 bg-white">
|
|
198
|
+
<p className="font-mono text-xs text-neutral-700">
|
|
199
|
+
Health Check:
|
|
200
|
+
<span className="text-green-500">{healthResult.healthy_count} healthy</span>
|
|
201
|
+
{healthResult.missing_count > 0 && (
|
|
202
|
+
<span className="text-red-500">
|
|
203
|
+
/ {healthResult.missing_count} missing
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
</p>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{/* Stats cards */}
|
|
211
|
+
<div className="grid grid-cols-4 gap-3">
|
|
212
|
+
{[
|
|
213
|
+
{ label: "Total", value: stats.total, color: "text-neutral-900" },
|
|
214
|
+
{ label: "Active", value: stats.active, color: "text-green-500" },
|
|
215
|
+
{ label: "Missing", value: stats.missing, color: "text-red-500" },
|
|
216
|
+
{ label: "New", value: stats.newAssets, color: "text-blue-400" },
|
|
217
|
+
].map((stat) => (
|
|
218
|
+
<div
|
|
219
|
+
key={stat.label}
|
|
220
|
+
className="p-3 rounded border border-neutral-200 bg-white shadow-sm"
|
|
221
|
+
>
|
|
222
|
+
<p className="font-mono text-[10px] text-neutral-400 uppercase mb-1">
|
|
223
|
+
{stat.label}
|
|
224
|
+
</p>
|
|
225
|
+
<p className={`font-mono text-xl ${stat.color}`}>{stat.value}</p>
|
|
226
|
+
</div>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Seed info */}
|
|
231
|
+
{registry && (
|
|
232
|
+
<div className="p-3 rounded border border-neutral-200 bg-white space-y-1">
|
|
233
|
+
<p className="font-mono text-[10px] text-neutral-400 uppercase">
|
|
234
|
+
Seed URL
|
|
235
|
+
</p>
|
|
236
|
+
<p className="font-mono text-xs text-neutral-700 break-all">
|
|
237
|
+
{registry.seed_url || "Not configured"}
|
|
238
|
+
</p>
|
|
239
|
+
<p className="font-mono text-[10px] text-neutral-400">
|
|
240
|
+
Last scan: {formatDate(registry.last_scanned_at)}
|
|
241
|
+
{registry.scan_error && (
|
|
242
|
+
<span className="text-red-500 ml-2">
|
|
243
|
+
Error: {registry.scan_error}
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
</p>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{/* Filters + Search */}
|
|
251
|
+
<div className="flex items-center gap-3">
|
|
252
|
+
<input
|
|
253
|
+
type="text"
|
|
254
|
+
value={searchQuery}
|
|
255
|
+
onChange={(e) => {
|
|
256
|
+
setSearchQuery(e.target.value);
|
|
257
|
+
setPage(0);
|
|
258
|
+
}}
|
|
259
|
+
placeholder="Search assets..."
|
|
260
|
+
className="flex-1 rounded border border-neutral-200 bg-white px-3 py-1.5 font-mono text-xs text-neutral-900 focus:border-[#076bff] focus:outline-none shadow-sm"
|
|
261
|
+
/>
|
|
262
|
+
<select
|
|
263
|
+
value={statusFilter}
|
|
264
|
+
onChange={(e) => {
|
|
265
|
+
setStatusFilter(e.target.value);
|
|
266
|
+
setPage(0);
|
|
267
|
+
}}
|
|
268
|
+
className="rounded border border-neutral-200 bg-white px-2 py-1.5 font-mono text-xs text-neutral-900 focus:border-[#076bff] focus:outline-none"
|
|
269
|
+
>
|
|
270
|
+
<option value="all">All statuses</option>
|
|
271
|
+
<option value="active">Active</option>
|
|
272
|
+
<option value="missing">Missing</option>
|
|
273
|
+
<option value="new">New</option>
|
|
274
|
+
<option value="moved">Moved</option>
|
|
275
|
+
</select>
|
|
276
|
+
<select
|
|
277
|
+
value={typeFilter}
|
|
278
|
+
onChange={(e) => {
|
|
279
|
+
setTypeFilter(e.target.value);
|
|
280
|
+
setPage(0);
|
|
281
|
+
}}
|
|
282
|
+
className="rounded border border-neutral-200 bg-white px-2 py-1.5 font-mono text-xs text-neutral-900 focus:border-[#076bff] focus:outline-none"
|
|
283
|
+
>
|
|
284
|
+
<option value="all">All types</option>
|
|
285
|
+
<option value="image">Images</option>
|
|
286
|
+
<option value="video">Videos</option>
|
|
287
|
+
</select>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Asset table */}
|
|
291
|
+
<div className="border border-neutral-200 rounded overflow-hidden">
|
|
292
|
+
{/* Table header */}
|
|
293
|
+
<div className="grid grid-cols-[1fr_80px_80px_60px_100px] gap-2 px-3 py-2 bg-neutral-50 border-b border-neutral-200">
|
|
294
|
+
<span className="font-mono text-[10px] text-neutral-400 uppercase">
|
|
295
|
+
Path
|
|
296
|
+
</span>
|
|
297
|
+
<span className="font-mono text-[10px] text-neutral-400 uppercase">
|
|
298
|
+
Type
|
|
299
|
+
</span>
|
|
300
|
+
<span className="font-mono text-[10px] text-neutral-400 uppercase">
|
|
301
|
+
Size
|
|
302
|
+
</span>
|
|
303
|
+
<span className="font-mono text-[10px] text-neutral-400 uppercase">
|
|
304
|
+
Status
|
|
305
|
+
</span>
|
|
306
|
+
<span className="font-mono text-[10px] text-neutral-400 uppercase">
|
|
307
|
+
Checked
|
|
308
|
+
</span>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* Table body */}
|
|
312
|
+
{paginatedAssets.length === 0 && (
|
|
313
|
+
<div className="flex items-center justify-center py-10">
|
|
314
|
+
<span className="font-mono text-xs text-neutral-400">
|
|
315
|
+
{stats.total === 0
|
|
316
|
+
? "No assets registered. Click Scan to discover files from storage."
|
|
317
|
+
: "No assets match your filters."}
|
|
318
|
+
</span>
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{paginatedAssets.map((asset) => (
|
|
323
|
+
<button
|
|
324
|
+
key={asset._key}
|
|
325
|
+
onClick={() =>
|
|
326
|
+
setSelectedAsset(
|
|
327
|
+
selectedAsset?._key === asset._key ? null : asset
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
className={`w-full grid grid-cols-[1fr_80px_80px_60px_100px] gap-2 px-3 py-2 border-b border-neutral-100 text-left transition-colors ${
|
|
331
|
+
selectedAsset?._key === asset._key
|
|
332
|
+
? "bg-[#076bff]/5"
|
|
333
|
+
: "hover:bg-neutral-50"
|
|
334
|
+
}`}
|
|
335
|
+
>
|
|
336
|
+
<span className="font-mono text-[11px] text-neutral-700 truncate">
|
|
337
|
+
{asset.path}
|
|
338
|
+
</span>
|
|
339
|
+
<span className="font-mono text-[10px] text-neutral-500 uppercase">
|
|
340
|
+
{asset.extension}
|
|
341
|
+
</span>
|
|
342
|
+
<span className="font-mono text-[10px] text-neutral-500">
|
|
343
|
+
{formatBytes(asset.file_size)}
|
|
344
|
+
</span>
|
|
345
|
+
<span
|
|
346
|
+
className={`font-mono text-[10px] font-bold ${getStatusColor(asset.status)}`}
|
|
347
|
+
>
|
|
348
|
+
{getStatusLabel(asset.status)}
|
|
349
|
+
</span>
|
|
350
|
+
<span className="font-mono text-[9px] text-neutral-400">
|
|
351
|
+
{asset.last_checked_at
|
|
352
|
+
? new Date(asset.last_checked_at).toLocaleDateString()
|
|
353
|
+
: "—"}
|
|
354
|
+
</span>
|
|
355
|
+
</button>
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
{/* Pagination */}
|
|
360
|
+
{totalPages > 1 && (
|
|
361
|
+
<div className="flex items-center justify-between">
|
|
362
|
+
<span className="font-mono text-[10px] text-neutral-400">
|
|
363
|
+
Showing {page * PAGE_SIZE + 1}–
|
|
364
|
+
{Math.min((page + 1) * PAGE_SIZE, filteredAssets.length)} of{" "}
|
|
365
|
+
{filteredAssets.length}
|
|
366
|
+
</span>
|
|
367
|
+
<div className="flex gap-1">
|
|
368
|
+
<button
|
|
369
|
+
onClick={() => setPage(Math.max(0, page - 1))}
|
|
370
|
+
disabled={page === 0}
|
|
371
|
+
className="font-mono text-[10px] px-2 py-1 rounded border border-neutral-300 text-neutral-500 hover:text-neutral-900 disabled:opacity-30"
|
|
372
|
+
>
|
|
373
|
+
Prev
|
|
374
|
+
</button>
|
|
375
|
+
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
|
|
376
|
+
<button
|
|
377
|
+
key={i}
|
|
378
|
+
onClick={() => setPage(i)}
|
|
379
|
+
className={`font-mono text-[10px] px-2 py-1 rounded border transition-colors ${
|
|
380
|
+
page === i
|
|
381
|
+
? "border-[#076bff] bg-[#076bff]/10 text-[#076bff]"
|
|
382
|
+
: "border-neutral-300 text-neutral-500 hover:text-neutral-900"
|
|
383
|
+
}`}
|
|
384
|
+
>
|
|
385
|
+
{i + 1}
|
|
386
|
+
</button>
|
|
387
|
+
))}
|
|
388
|
+
<button
|
|
389
|
+
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
|
390
|
+
disabled={page >= totalPages - 1}
|
|
391
|
+
className="font-mono text-[10px] px-2 py-1 rounded border border-neutral-300 text-neutral-500 hover:text-neutral-900 disabled:opacity-30"
|
|
392
|
+
>
|
|
393
|
+
Next
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{/* Selected asset detail */}
|
|
400
|
+
{selectedAsset && (
|
|
401
|
+
<div className="p-4 rounded border border-neutral-200 bg-white space-y-3">
|
|
402
|
+
<div className="flex items-center justify-between">
|
|
403
|
+
<h3 className="font-mono text-sm text-neutral-900">Asset Details</h3>
|
|
404
|
+
<button
|
|
405
|
+
onClick={() => setSelectedAsset(null)}
|
|
406
|
+
className="text-neutral-400 hover:text-neutral-900"
|
|
407
|
+
>
|
|
408
|
+
✕
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div className="grid grid-cols-2 gap-4">
|
|
413
|
+
<div className="space-y-2">
|
|
414
|
+
<div>
|
|
415
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
416
|
+
Full Path
|
|
417
|
+
</p>
|
|
418
|
+
<p className="font-mono text-xs text-neutral-700 break-all">
|
|
419
|
+
{selectedAsset.path}
|
|
420
|
+
</p>
|
|
421
|
+
</div>
|
|
422
|
+
<div>
|
|
423
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
424
|
+
Filename
|
|
425
|
+
</p>
|
|
426
|
+
<p className="font-mono text-xs text-neutral-900">
|
|
427
|
+
{selectedAsset.filename}
|
|
428
|
+
</p>
|
|
429
|
+
</div>
|
|
430
|
+
<div className="flex gap-4">
|
|
431
|
+
<div>
|
|
432
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
433
|
+
Size
|
|
434
|
+
</p>
|
|
435
|
+
<p className="font-mono text-xs text-neutral-700">
|
|
436
|
+
{formatBytes(selectedAsset.file_size)}
|
|
437
|
+
</p>
|
|
438
|
+
</div>
|
|
439
|
+
<div>
|
|
440
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
441
|
+
MIME Type
|
|
442
|
+
</p>
|
|
443
|
+
<p className="font-mono text-xs text-neutral-700">
|
|
444
|
+
{selectedAsset.mime_type || "—"}
|
|
445
|
+
</p>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
{selectedAsset.width && selectedAsset.height && (
|
|
449
|
+
<div>
|
|
450
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
451
|
+
Dimensions
|
|
452
|
+
</p>
|
|
453
|
+
<p className="font-mono text-xs text-neutral-700">
|
|
454
|
+
{selectedAsset.width} × {selectedAsset.height}px
|
|
455
|
+
</p>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
<div>
|
|
459
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
460
|
+
Status
|
|
461
|
+
</p>
|
|
462
|
+
<p
|
|
463
|
+
className={`font-mono text-xs font-bold ${getStatusColor(selectedAsset.status)}`}
|
|
464
|
+
>
|
|
465
|
+
{getStatusLabel(selectedAsset.status)}
|
|
466
|
+
</p>
|
|
467
|
+
</div>
|
|
468
|
+
{selectedAsset.used_in && selectedAsset.used_in.length > 0 && (
|
|
469
|
+
<div>
|
|
470
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
471
|
+
Used in ({selectedAsset.used_in.length} documents)
|
|
472
|
+
</p>
|
|
473
|
+
<div className="flex flex-wrap gap-1 mt-1">
|
|
474
|
+
{selectedAsset.used_in.map((docId) => (
|
|
475
|
+
<span
|
|
476
|
+
key={docId}
|
|
477
|
+
className="font-mono text-[9px] text-neutral-600 bg-neutral-100 px-1.5 py-0.5 rounded"
|
|
478
|
+
>
|
|
479
|
+
{docId}
|
|
480
|
+
</span>
|
|
481
|
+
))}
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
)}
|
|
485
|
+
{selectedAsset.file_hash && (
|
|
486
|
+
<div>
|
|
487
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
488
|
+
Content Hash
|
|
489
|
+
</p>
|
|
490
|
+
<p className="font-mono text-[9px] text-neutral-500 break-all">
|
|
491
|
+
{selectedAsset.file_hash}
|
|
492
|
+
</p>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
{/* Preview */}
|
|
498
|
+
<div className="flex items-center justify-center bg-neutral-50 rounded border border-neutral-200 min-h-[150px]">
|
|
499
|
+
{registry?.seed_url &&
|
|
500
|
+
["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(
|
|
501
|
+
selectedAsset.extension
|
|
502
|
+
) ? (
|
|
503
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
504
|
+
<img
|
|
505
|
+
src={`${registry.seed_url.replace(/\/$/, "")}/${selectedAsset.path}`}
|
|
506
|
+
alt={selectedAsset.filename}
|
|
507
|
+
className="max-w-full max-h-[300px] object-contain"
|
|
508
|
+
onError={(e) => {
|
|
509
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
510
|
+
}}
|
|
511
|
+
/>
|
|
512
|
+
) : (
|
|
513
|
+
<span className="text-4xl">
|
|
514
|
+
{["mp4", "webm", "mov"].includes(selectedAsset.extension)
|
|
515
|
+
? "🎬"
|
|
516
|
+
: "📄"}
|
|
517
|
+
</span>
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
{/* Resolved URL */}
|
|
523
|
+
{registry?.seed_url && (
|
|
524
|
+
<div>
|
|
525
|
+
<p className="font-mono text-[9px] text-neutral-400 uppercase">
|
|
526
|
+
Resolved URL
|
|
527
|
+
</p>
|
|
528
|
+
<p className="font-mono text-[10px] text-neutral-500 break-all">
|
|
529
|
+
{registry.seed_url.replace(/\/$/, "")}/{selectedAsset.path}
|
|
530
|
+
</p>
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
|
|
536
|
+
{/* Relink Log */}
|
|
537
|
+
{registry?.relink_log && registry.relink_log.length > 0 && (
|
|
538
|
+
<div className="space-y-2">
|
|
539
|
+
<h3 className="font-mono text-xs text-neutral-500 uppercase tracking-wider">
|
|
540
|
+
Relink History
|
|
541
|
+
</h3>
|
|
542
|
+
<div className="space-y-1">
|
|
543
|
+
{[...registry.relink_log].reverse().map((entry: RelinkLogEntry) => (
|
|
544
|
+
<div
|
|
545
|
+
key={entry._key}
|
|
546
|
+
className="p-2 rounded border border-neutral-200 bg-white"
|
|
547
|
+
>
|
|
548
|
+
<div className="flex items-center justify-between">
|
|
549
|
+
<span className="font-mono text-[10px] text-neutral-500">
|
|
550
|
+
{formatDate(entry.date)}
|
|
551
|
+
</span>
|
|
552
|
+
<span className="font-mono text-[10px]">
|
|
553
|
+
<span className="text-green-500">
|
|
554
|
+
{entry.assets_relinked} relinked
|
|
555
|
+
</span>
|
|
556
|
+
{entry.assets_missing > 0 && (
|
|
557
|
+
<span className="text-red-500 ml-2">
|
|
558
|
+
{entry.assets_missing} missing
|
|
559
|
+
</span>
|
|
560
|
+
)}
|
|
561
|
+
</span>
|
|
562
|
+
</div>
|
|
563
|
+
<p className="font-mono text-[9px] text-neutral-400 mt-1 truncate">
|
|
564
|
+
{entry.old_seed} → {entry.new_seed}
|
|
565
|
+
</p>
|
|
566
|
+
</div>
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
572
|
+
);
|
|
573
|
+
}
|