@buildpad/cli 0.1.4

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 (44) hide show
  1. package/README.md +557 -0
  2. package/dist/chunk-J4KKVECI.js +365 -0
  3. package/dist/chunk-J4KKVECI.js.map +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +2582 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/outdated-JMAYAZ7W.js +110 -0
  8. package/dist/outdated-JMAYAZ7W.js.map +1 -0
  9. package/dist/templates/api/auth-callback-route.ts +36 -0
  10. package/dist/templates/api/auth-headers.ts +72 -0
  11. package/dist/templates/api/auth-login-route.ts +63 -0
  12. package/dist/templates/api/auth-logout-route.ts +41 -0
  13. package/dist/templates/api/auth-user-route.ts +71 -0
  14. package/dist/templates/api/collections-route.ts +54 -0
  15. package/dist/templates/api/fields-route.ts +44 -0
  16. package/dist/templates/api/files-id-route.ts +116 -0
  17. package/dist/templates/api/files-route.ts +83 -0
  18. package/dist/templates/api/items-id-route.ts +120 -0
  19. package/dist/templates/api/items-route.ts +88 -0
  20. package/dist/templates/api/login-page.tsx +142 -0
  21. package/dist/templates/api/permissions-me-route.ts +72 -0
  22. package/dist/templates/api/relations-route.ts +46 -0
  23. package/dist/templates/app/content/[collection]/[id]/page.tsx +35 -0
  24. package/dist/templates/app/content/[collection]/page.tsx +65 -0
  25. package/dist/templates/app/content/layout.tsx +64 -0
  26. package/dist/templates/app/content/page.tsx +66 -0
  27. package/dist/templates/app/design-tokens.css +183 -0
  28. package/dist/templates/app/globals.css +58 -0
  29. package/dist/templates/app/layout.tsx +49 -0
  30. package/dist/templates/app/page.tsx +23 -0
  31. package/dist/templates/components/ColorSchemeToggle.tsx +35 -0
  32. package/dist/templates/lib/common-utils.ts +156 -0
  33. package/dist/templates/lib/hooks/index.ts +98 -0
  34. package/dist/templates/lib/services/index.ts +31 -0
  35. package/dist/templates/lib/theme.ts +241 -0
  36. package/dist/templates/lib/types/index.ts +10 -0
  37. package/dist/templates/lib/utils-index.ts +32 -0
  38. package/dist/templates/lib/utils.ts +14 -0
  39. package/dist/templates/lib/vform/index.ts +24 -0
  40. package/dist/templates/middleware/middleware.ts +29 -0
  41. package/dist/templates/supabase/client.ts +25 -0
  42. package/dist/templates/supabase/middleware.ts +66 -0
  43. package/dist/templates/supabase/server.ts +45 -0
  44. package/package.json +61 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Content Module Layout
3
+ *
4
+ * Provides a content management shell with sidebar collection navigation,
5
+ * header bar, and main content area. Wraps all /content/* pages.
6
+ *
7
+ * Generated by @buildpad/cli
8
+ */
9
+
10
+ "use client";
11
+
12
+ import React from 'react';
13
+ import { usePathname, useRouter } from 'next/navigation';
14
+ import { ContentLayout } from '@/components/ui/content-layout';
15
+ import { ContentNavigation } from '@/components/ui/content-navigation';
16
+ import { useCollections } from '@/lib/buildpad/hooks';
17
+
18
+ export default function ContentModuleLayout({
19
+ children,
20
+ }: {
21
+ children: React.ReactNode;
22
+ }) {
23
+ const router = useRouter();
24
+ const pathname = usePathname();
25
+
26
+ // Extract current collection from URL: /content/[collection]/...
27
+ const segments = pathname.split('/');
28
+ const currentCollection = segments.length > 2 ? segments[2] : undefined;
29
+
30
+ const {
31
+ rootCollections,
32
+ activeGroups,
33
+ toggleGroup,
34
+ showHidden,
35
+ setShowHidden,
36
+ hasHiddenCollections,
37
+ showSearch,
38
+ dense,
39
+ loading,
40
+ } = useCollections({ currentCollection });
41
+
42
+ return (
43
+ <ContentLayout
44
+ breadcrumbs={[{ label: 'Content', href: '/content' }]}
45
+ sidebar={
46
+ <ContentNavigation
47
+ rootCollections={rootCollections}
48
+ activeGroups={activeGroups}
49
+ onToggleGroup={toggleGroup}
50
+ currentCollection={currentCollection}
51
+ onNavigate={(collection) => router.push(`/content/${collection}`)}
52
+ showHidden={showHidden}
53
+ onToggleHidden={() => setShowHidden(!showHidden)}
54
+ hasHiddenCollections={hasHiddenCollections}
55
+ showSearch={showSearch}
56
+ dense={dense}
57
+ loading={loading}
58
+ />
59
+ }
60
+ >
61
+ {children}
62
+ </ContentLayout>
63
+ );
64
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Content Module Index Page
3
+ *
4
+ * Redirects to the first available collection, or shows an empty state
5
+ * if there are no collections yet.
6
+ *
7
+ * Generated by @buildpad/cli
8
+ */
9
+
10
+ "use client";
11
+
12
+ import React, { useEffect } from 'react';
13
+ import { useRouter } from 'next/navigation';
14
+ import { Stack, Text, Button, Center } from '@mantine/core';
15
+ import { IconBox, IconPlus } from '@tabler/icons-react';
16
+ import { useCollections } from '@/lib/buildpad/hooks';
17
+ import { useLocalStorage } from '@/lib/buildpad/hooks';
18
+
19
+ export default function ContentIndexPage() {
20
+ const router = useRouter();
21
+ const { visibleCollections, loading } = useCollections();
22
+ const { value: lastAccessed } = useLocalStorage<string>('last-accessed-collection');
23
+
24
+ useEffect(() => {
25
+ if (loading) return;
26
+
27
+ // Redirect to last accessed collection if it still exists
28
+ if (lastAccessed) {
29
+ const exists = visibleCollections.find((c) => c.collection === lastAccessed);
30
+ if (exists) {
31
+ router.replace(`/content/${lastAccessed}`);
32
+ return;
33
+ }
34
+ }
35
+
36
+ // Redirect to first available collection
37
+ if (visibleCollections.length > 0) {
38
+ const first = visibleCollections.find((c) => !!c.schema);
39
+ if (first) {
40
+ router.replace(`/content/${first.collection}`);
41
+ return;
42
+ }
43
+ }
44
+ }, [loading, lastAccessed, visibleCollections, router]);
45
+
46
+ if (loading) return null;
47
+
48
+ // No collections — show empty state
49
+ if (visibleCollections.length === 0) {
50
+ return (
51
+ <Center style={{ minHeight: '60vh' }}>
52
+ <Stack align="center" gap="md">
53
+ <IconBox size={64} color="var(--mantine-color-gray-5)" stroke={1.5} />
54
+ <Text size="xl" fw={600}>
55
+ No Collections
56
+ </Text>
57
+ <Text c="dimmed" ta="center" maw={400}>
58
+ There are no collections available. Create your first collection in the data model settings.
59
+ </Text>
60
+ </Stack>
61
+ </Center>
62
+ );
63
+ }
64
+
65
+ return null;
66
+ }
@@ -0,0 +1,183 @@
1
+ /* Design system tokens */
2
+
3
+ :root {
4
+ /* Colors - full shade scale */
5
+ --ds-primary-100: #ece6fb;
6
+ --ds-primary-200: #cfcbfd;
7
+ --ds-primary-300: #a29bfb;
8
+ --ds-primary-400: #7857ff;
9
+ --ds-primary: #5925dc;
10
+ --ds-primary-600: #491db6;
11
+ --ds-primary-700: #39178e;
12
+ --ds-primary-800: #291167;
13
+ --ds-primary-900: #190a3f;
14
+ --ds-primary-950: #0f0629;
15
+
16
+ --ds-secondary-100: #ebf1ff;
17
+ --ds-secondary-200: #d3e2ff;
18
+ --ds-secondary-300: #99bbff;
19
+ --ds-secondary-400: #70a0ff;
20
+ --ds-secondary: #1f69ff;
21
+ --ds-secondary-600: #004ff0;
22
+ --ds-secondary-700: #0040c2;
23
+ --ds-secondary-800: #003194;
24
+ --ds-secondary-900: #002266;
25
+ --ds-secondary-950: #001a4d;
26
+
27
+ --ds-success-100: #ecfbee;
28
+ --ds-success-200: #c4e8c8;
29
+ --ds-success-300: #9dd9a3;
30
+ --ds-success-400: #58be62;
31
+ --ds-success-500: #3bb346;
32
+ --ds-success: #198754;
33
+ --ds-success-700: #2da337;
34
+ --ds-success-800: #196f25;
35
+ --ds-success-900: #0d4f15;
36
+ --ds-success-950: #0a3e11;
37
+
38
+ --ds-info-100: #e6f3fb;
39
+ --ds-info-200: #b9d8ee;
40
+ --ds-info-300: #90c1e4;
41
+ --ds-info-400: #58a1d4;
42
+ --ds-info-500: #59a1d4;
43
+ --ds-info: #0f71bb;
44
+ --ds-info-700: #0c5b97;
45
+ --ds-info-800: #0a4776;
46
+ --ds-info-900: #08395e;
47
+ --ds-info-950: #062b47;
48
+
49
+ --ds-warning-100: #fffaeb;
50
+ --ds-warning-200: #fef0c7;
51
+ --ds-warning-300: #fedf89;
52
+ --ds-warning-400: #fec84b;
53
+ --ds-warning-500: #fdb022;
54
+ --ds-warning: #f79009;
55
+ --ds-warning-700: #dc6803;
56
+ --ds-warning-800: #b7571e;
57
+ --ds-warning-900: #8f4419;
58
+ --ds-warning-950: #6d3314;
59
+
60
+ --ds-danger-100: #fff4f3;
61
+ --ds-danger-200: #ffcfc8;
62
+ --ds-danger-300: #fc9c90;
63
+ --ds-danger-400: #fb7463;
64
+ --ds-danger-500: #fa5741;
65
+ --ds-danger: #d7260f;
66
+ --ds-danger-700: #f8331c;
67
+ --ds-danger-800: #c4281a;
68
+ --ds-danger-900: #9a1f15;
69
+ --ds-danger-950: #72170f;
70
+
71
+ --ds-gray-100: #f7f7f9;
72
+ --ds-gray-200: #e4e7ec;
73
+ --ds-gray-300: #d0d5dd;
74
+ --ds-gray-400: #98a2b3;
75
+ --ds-gray-500: #667085;
76
+ --ds-gray-600: #344054;
77
+ --ds-gray-700: #1d2939;
78
+ --ds-gray-800: #101828;
79
+ --ds-gray-900: #000000;
80
+ --ds-gray-950: #000000;
81
+
82
+ /* Spacing (8px base) */
83
+ --ds-spacing-1: 0.25rem;
84
+ --ds-spacing-2: 0.5rem;
85
+ --ds-spacing-3: 0.75rem;
86
+ --ds-spacing-4: 1rem;
87
+ --ds-spacing-5: 1.25rem;
88
+ --ds-spacing-6: 1.5rem;
89
+ --ds-spacing-8: 2rem;
90
+ --ds-spacing-10: 2.5rem;
91
+ --ds-spacing-12: 3rem;
92
+ --ds-spacing-16: 4rem;
93
+
94
+ /* Typography */
95
+ --ds-font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
96
+ --ds-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
97
+ --ds-font-size-xs: 0.75rem;
98
+ --ds-font-size-sm: 0.875rem;
99
+ --ds-font-size-base: 1rem;
100
+ --ds-body-font-size: 1rem;
101
+ --ds-font-size-lg: 1.125rem;
102
+ --ds-font-size-xl: 1.25rem;
103
+ --ds-font-size-2xl: 1.5rem;
104
+ --ds-font-size-3xl: 1.875rem;
105
+ --ds-font-size-4xl: 2.25rem;
106
+
107
+ --ds-font-weight-normal: 400;
108
+ --ds-font-weight-base: 400;
109
+ --ds-font-weight-medium: 500;
110
+ --ds-font-weight-semibold: 600;
111
+ --ds-font-weight-bold: 700;
112
+
113
+ --ds-line-height-tight: 1.25;
114
+ --ds-line-height-normal: 1.5;
115
+ --ds-line-height-base: 1.5;
116
+ --ds-line-height-relaxed: 1.75;
117
+
118
+ /* Radius */
119
+ --ds-radius-sm: 0.25rem;
120
+ --ds-radius: 0.375rem;
121
+ --ds-radius-md: 0.5rem;
122
+ --ds-radius-lg: 0.75rem;
123
+ --ds-radius-xl: 1rem;
124
+
125
+ /* Shadows */
126
+ --ds-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
127
+ --ds-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
128
+ --ds-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
129
+ --ds-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
130
+ --ds-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
131
+
132
+ /* Base colors */
133
+ --ds-body-bg: #ffffff;
134
+ --ds-body-color: var(--ds-gray-600);
135
+ --ds-body-color-rgb: 52, 64, 84;
136
+ --ds-primary-rgb: 89, 37, 220;
137
+
138
+ /* States */
139
+ --ds-hover-opacity: 0.8;
140
+ --ds-focus-ring: 0 0 0 2px var(--ds-primary);
141
+ --ds-disabled-opacity: 0.6;
142
+ }
143
+
144
+ [data-mantine-color-scheme="dark"] {
145
+ --ds-primary: #7857ff;
146
+ --ds-primary-100: #291167;
147
+ --ds-primary-200: #39178e;
148
+ --ds-primary-300: #491db6;
149
+ --ds-primary-400: #5925dc;
150
+ --ds-primary-600: #a29bfb;
151
+ --ds-primary-700: #cfcbfd;
152
+ --ds-primary-800: #ece6fb;
153
+ --ds-primary-900: #f5f0ff;
154
+ --ds-primary-950: #faf8ff;
155
+
156
+ --ds-secondary: #70a0ff;
157
+ --ds-success: #58be62;
158
+ --ds-info: #58a1d4;
159
+ --ds-warning: #fec84b;
160
+ --ds-danger: #fb7463;
161
+
162
+ --ds-gray-100: #1a1b1e;
163
+ --ds-gray-200: #25262b;
164
+ --ds-gray-300: #2c2e33;
165
+ --ds-gray-400: #373a40;
166
+ --ds-gray-500: #909296;
167
+ --ds-gray-600: #c1c2c5;
168
+ --ds-gray-700: #dee2e6;
169
+ --ds-gray-800: #e9ecef;
170
+ --ds-gray-900: #f8f9fa;
171
+ --ds-gray-950: #ffffff;
172
+
173
+ --ds-body-bg: #1a1b1e;
174
+ --ds-body-color: #c1c2c5;
175
+ --ds-body-color-rgb: 193, 194, 197;
176
+ --ds-primary-rgb: 120, 87, 255;
177
+
178
+ --ds-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
179
+ --ds-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.4), 0 1px 3px -1px rgba(0, 0, 0, 0.3);
180
+ --ds-shadow-md: 0 4px 8px -1px rgba(0, 0, 0, 0.4), 0 2px 6px -2px rgba(0, 0, 0, 0.3);
181
+ --ds-shadow-lg: 0 10px 20px -3px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
182
+ --ds-shadow-xl: 0 20px 30px -5px rgba(0, 0, 0, 0.4), 0 8px 12px -6px rgba(0, 0, 0, 0.3);
183
+ }
@@ -0,0 +1,58 @@
1
+ /* Global styles using design tokens */
2
+ body {
3
+ background: var(--ds-body-bg);
4
+ color: var(--ds-body-color);
5
+ font-family: var(--ds-font-family);
6
+ font-size: var(--ds-body-font-size);
7
+ line-height: var(--ds-line-height-base);
8
+ }
9
+
10
+ .emoji-icon {
11
+ font-size: 1.2rem;
12
+ line-height: 1;
13
+ }
14
+
15
+ .mantine-Tabs-tab:hover {
16
+ background-color: var(--ds-gray-100);
17
+ color: var(--ds-gray-700);
18
+ }
19
+
20
+ .mantine-Tabs-tab[data-active],
21
+ .mantine-Tabs-tab[data-active]:hover {
22
+ color: var(--ds-primary);
23
+ border-bottom-color: var(--ds-primary);
24
+ font-weight: 600;
25
+ }
26
+
27
+ .mantine-Notification-root[data-variant="success"] {
28
+ border-left-color: var(--ds-success);
29
+ }
30
+
31
+ .mantine-Notification-root[data-variant="error"] {
32
+ border-left-color: var(--ds-danger);
33
+ }
34
+
35
+ .mantine-Notification-root[data-variant="warning"] {
36
+ border-left-color: var(--ds-warning);
37
+ }
38
+
39
+ .mantine-Notification-root[data-variant="info"] {
40
+ border-left-color: var(--ds-info);
41
+ }
42
+
43
+ .mantine-Input-input:focus {
44
+ border-color: var(--mantine-color-primary-6);
45
+ }
46
+
47
+ .mantine-Modal-close:hover {
48
+ background-color: var(--ds-gray-100);
49
+ }
50
+
51
+ .mantine-Anchor-root:hover {
52
+ text-decoration: underline;
53
+ color: var(--mantine-color-primary-6);
54
+ }
55
+
56
+ .mantine-ActionIcon-root:hover {
57
+ background-color: var(--ds-gray-100);
58
+ }
@@ -0,0 +1,49 @@
1
+ import type { Metadata } from "next";
2
+ import {
3
+ ColorSchemeScript,
4
+ MantineProvider,
5
+ mantineHtmlProps
6
+ } from "@mantine/core";
7
+ import { ModalsProvider } from "@mantine/modals";
8
+ import { Notifications } from "@mantine/notifications";
9
+ import { Inter } from "next/font/google";
10
+ import "@mantine/core/styles.css";
11
+ import "@mantine/notifications/styles.css";
12
+ import "./design-tokens.css";
13
+ import "./globals.css";
14
+ import { theme } from "@/lib/theme";
15
+
16
+ const inter = Inter({
17
+ variable: "--font-inter",
18
+ subsets: ["latin"]
19
+ });
20
+
21
+ export const metadata: Metadata = {
22
+ title: "Buildpad App",
23
+ description: "DaaS-ready Next.js app with a token-based design system"
24
+ };
25
+
26
+ export default function RootLayout({
27
+ children
28
+ }: Readonly<{ children: React.ReactNode }>) {
29
+ return (
30
+ <html lang="en" {...mantineHtmlProps}>
31
+ <head>
32
+ <ColorSchemeScript />
33
+ <link rel="shortcut icon" href="/favicon.ico" />
34
+ <meta
35
+ name="viewport"
36
+ content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
37
+ />
38
+ </head>
39
+ <body className={inter.variable}>
40
+ <MantineProvider theme={theme} defaultColorScheme="auto">
41
+ <ModalsProvider>
42
+ <Notifications position="top-right" />
43
+ {children}
44
+ </ModalsProvider>
45
+ </MantineProvider>
46
+ </body>
47
+ </html>
48
+ );
49
+ }
@@ -0,0 +1,23 @@
1
+ import { Button, Group, Stack, Text, Title } from "@mantine/core";
2
+ import { ColorSchemeToggle } from "@/components/ColorSchemeToggle";
3
+
4
+ export default function HomePage() {
5
+ return (
6
+ <Stack gap="lg" p="xl">
7
+ <Group justify="space-between">
8
+ <Title order={2}>Buildpad Starter</Title>
9
+ <ColorSchemeToggle />
10
+ </Group>
11
+ <Text c="dimmed">
12
+ This starter uses token-based theming with Mantine and is ready to
13
+ consume Buildpad UI components.
14
+ </Text>
15
+ <Group>
16
+ <Button>Primary Action</Button>
17
+ <Button variant="light" color="secondary">
18
+ Secondary Action
19
+ </Button>
20
+ </Group>
21
+ </Stack>
22
+ );
23
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import {
4
+ ActionIcon,
5
+ Tooltip,
6
+ useComputedColorScheme,
7
+ useMantineColorScheme
8
+ } from "@mantine/core";
9
+ import { IconMoon, IconSun } from "@tabler/icons-react";
10
+
11
+ export function ColorSchemeToggle() {
12
+ const { setColorScheme } = useMantineColorScheme();
13
+ const computedColorScheme = useComputedColorScheme("light", {
14
+ getInitialValueInEffect: true
15
+ });
16
+
17
+ return (
18
+ <Tooltip label={computedColorScheme === "dark" ? "Light mode" : "Dark mode"}>
19
+ <ActionIcon
20
+ variant="subtle"
21
+ size="lg"
22
+ onClick={() =>
23
+ setColorScheme(computedColorScheme === "light" ? "dark" : "light")
24
+ }
25
+ aria-label="Toggle color scheme"
26
+ >
27
+ {computedColorScheme === "dark" ? (
28
+ <IconSun size={20} />
29
+ ) : (
30
+ <IconMoon size={20} />
31
+ )}
32
+ </ActionIcon>
33
+ </Tooltip>
34
+ );
35
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Buildpad Utilities
3
+ *
4
+ * Shared utility functions for Buildpad components.
5
+ * This file is copied to your project and can be customized.
6
+ */
7
+
8
+ import { clsx, type ClassValue } from 'clsx';
9
+ import { twMerge } from 'tailwind-merge';
10
+
11
+ /**
12
+ * Merge class names with Tailwind CSS conflict resolution
13
+ * Like shadcn's cn() utility
14
+ */
15
+ export function cn(...inputs: ClassValue[]) {
16
+ return twMerge(clsx(inputs));
17
+ }
18
+
19
+ /**
20
+ * Format file size from bytes to human-readable string
21
+ */
22
+ export function formatFileSize(bytes: number): string {
23
+ if (bytes === 0) return '0 B';
24
+
25
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
26
+ const k = 1024;
27
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
28
+
29
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`;
30
+ }
31
+
32
+ /**
33
+ * Get file category based on MIME type
34
+ */
35
+ export function getFileCategory(mimeType: string | null): 'image' | 'video' | 'audio' | 'document' | 'other' {
36
+ if (!mimeType) return 'other';
37
+
38
+ if (mimeType.startsWith('image/')) return 'image';
39
+ if (mimeType.startsWith('video/')) return 'video';
40
+ if (mimeType.startsWith('audio/')) return 'audio';
41
+ if (
42
+ mimeType.startsWith('text/') ||
43
+ mimeType.includes('pdf') ||
44
+ mimeType.includes('document') ||
45
+ mimeType.includes('spreadsheet') ||
46
+ mimeType.includes('presentation')
47
+ ) {
48
+ return 'document';
49
+ }
50
+
51
+ return 'other';
52
+ }
53
+
54
+ /**
55
+ * Get asset URL for a file
56
+ */
57
+ export function getAssetUrl(fileId: string, options?: {
58
+ width?: number;
59
+ height?: number;
60
+ fit?: 'cover' | 'contain' | 'inside' | 'outside';
61
+ quality?: number;
62
+ format?: 'auto' | 'webp' | 'avif' | 'jpg' | 'png';
63
+ }): string {
64
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL || '/api';
65
+ let url = `${baseUrl}/assets/${fileId}`;
66
+
67
+ if (options) {
68
+ const params = new URLSearchParams();
69
+ if (options.width) params.set('width', options.width.toString());
70
+ if (options.height) params.set('height', options.height.toString());
71
+ if (options.fit) params.set('fit', options.fit);
72
+ if (options.quality) params.set('quality', options.quality.toString());
73
+ if (options.format) params.set('format', options.format);
74
+
75
+ const queryString = params.toString();
76
+ if (queryString) url += `?${queryString}`;
77
+ }
78
+
79
+ return url;
80
+ }
81
+
82
+ /**
83
+ * Debounce a function call
84
+ */
85
+ export function debounce<T extends (...args: unknown[]) => unknown>(
86
+ func: T,
87
+ wait: number
88
+ ): (...args: Parameters<T>) => void {
89
+ let timeout: NodeJS.Timeout;
90
+
91
+ return function executedFunction(...args: Parameters<T>) {
92
+ clearTimeout(timeout);
93
+ timeout = setTimeout(() => func(...args), wait);
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Check if a value is a valid primary key
99
+ */
100
+ export function isValidPrimaryKey(value: unknown): value is string | number {
101
+ if (typeof value === 'number') return !isNaN(value);
102
+ if (typeof value === 'string') return value.length > 0 && value !== '+';
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Deep merge two objects
108
+ */
109
+ export function deepMerge<T extends Record<string, unknown>>(
110
+ target: T,
111
+ source: Partial<T>
112
+ ): T {
113
+ const result = { ...target };
114
+
115
+ for (const key in source) {
116
+ const sourceValue = source[key];
117
+ const targetValue = result[key];
118
+
119
+ if (
120
+ sourceValue &&
121
+ typeof sourceValue === 'object' &&
122
+ !Array.isArray(sourceValue) &&
123
+ targetValue &&
124
+ typeof targetValue === 'object' &&
125
+ !Array.isArray(targetValue)
126
+ ) {
127
+ result[key] = deepMerge(
128
+ targetValue as Record<string, unknown>,
129
+ sourceValue as Record<string, unknown>
130
+ ) as T[Extract<keyof T, string>];
131
+ } else {
132
+ result[key] = sourceValue as T[Extract<keyof T, string>];
133
+ }
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Convert a string to slug format
141
+ */
142
+ export function slugify(text: string): string {
143
+ return text
144
+ .toLowerCase()
145
+ .trim()
146
+ .replace(/[^\w\s-]/g, '')
147
+ .replace(/[\s_-]+/g, '-')
148
+ .replace(/^-+|-+$/g, '');
149
+ }
150
+
151
+ /**
152
+ * Generate a unique ID
153
+ */
154
+ export function generateId(): string {
155
+ return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
156
+ }