@microbuild/cli 0.1.0

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 (38) hide show
  1. package/README.md +555 -0
  2. package/dist/chunk-6YA3DSAE.js +362 -0
  3. package/dist/chunk-6YA3DSAE.js.map +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +2279 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/outdated-TV5ERBNC.js +110 -0
  8. package/dist/outdated-TV5ERBNC.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/fields-route.ts +44 -0
  15. package/dist/templates/api/files-id-route.ts +116 -0
  16. package/dist/templates/api/files-route.ts +83 -0
  17. package/dist/templates/api/items-id-route.ts +120 -0
  18. package/dist/templates/api/items-route.ts +88 -0
  19. package/dist/templates/api/login-page.tsx +142 -0
  20. package/dist/templates/api/relations-route.ts +46 -0
  21. package/dist/templates/app/design-tokens.css +183 -0
  22. package/dist/templates/app/globals.css +58 -0
  23. package/dist/templates/app/layout.tsx +49 -0
  24. package/dist/templates/app/page.tsx +23 -0
  25. package/dist/templates/components/ColorSchemeToggle.tsx +35 -0
  26. package/dist/templates/lib/common-utils.ts +156 -0
  27. package/dist/templates/lib/hooks/index.ts +98 -0
  28. package/dist/templates/lib/services/index.ts +26 -0
  29. package/dist/templates/lib/theme.ts +241 -0
  30. package/dist/templates/lib/types/index.ts +10 -0
  31. package/dist/templates/lib/utils-index.ts +32 -0
  32. package/dist/templates/lib/utils.ts +14 -0
  33. package/dist/templates/lib/vform/index.ts +24 -0
  34. package/dist/templates/middleware/middleware.ts +29 -0
  35. package/dist/templates/supabase/client.ts +25 -0
  36. package/dist/templates/supabase/middleware.ts +66 -0
  37. package/dist/templates/supabase/server.ts +45 -0
  38. package/package.json +61 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Login Page Template
3
+ *
4
+ * Server-side proxy login page that uses the /api/auth/login proxy route
5
+ * instead of calling Supabase directly from the browser.
6
+ * This avoids CORS issues in the two-tier architecture.
7
+ *
8
+ * Pattern: Browser → /api/auth/login (same origin) → Supabase Auth (server-side)
9
+ *
10
+ * @microbuild/origin: pages/login
11
+ * @microbuild/version: 1.0.0
12
+ */
13
+
14
+ 'use client';
15
+
16
+ import { useState } from 'react';
17
+ import {
18
+ Paper,
19
+ TextInput,
20
+ PasswordInput,
21
+ Button,
22
+ Title,
23
+ Text,
24
+ Container,
25
+ Stack,
26
+ Box,
27
+ Group,
28
+ Anchor,
29
+ } from '@mantine/core';
30
+ import { useForm } from '@mantine/form';
31
+ import { notifications } from '@mantine/notifications';
32
+ import { useRouter } from 'next/navigation';
33
+
34
+ export default function LoginPage() {
35
+ const router = useRouter();
36
+ const [loading, setLoading] = useState(false);
37
+
38
+ const form = useForm({
39
+ initialValues: {
40
+ email: '',
41
+ password: '',
42
+ },
43
+ validate: {
44
+ email: (value) => (!value ? 'Email is required' : /^\S+@\S+$/.test(value) ? null : 'Invalid email'),
45
+ password: (value) => (!value ? 'Password is required' : null),
46
+ },
47
+ });
48
+
49
+ const handleLogin = async (values: { email: string; password: string }) => {
50
+ setLoading(true);
51
+
52
+ try {
53
+ // Use the proxy route — NOT the Supabase client directly
54
+ // This avoids CORS issues because the request stays same-origin
55
+ const response = await fetch('/api/auth/login', {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(values),
59
+ credentials: 'include', // Include cookies
60
+ });
61
+
62
+ const data = await response.json();
63
+
64
+ if (!response.ok) {
65
+ throw new Error(data.errors?.[0]?.message || 'Login failed');
66
+ }
67
+
68
+ notifications.show({
69
+ title: 'Success',
70
+ message: 'Logged in successfully',
71
+ color: 'green',
72
+ });
73
+
74
+ router.push('/');
75
+ router.refresh();
76
+ } catch (error) {
77
+ console.error('Login error:', error);
78
+ notifications.show({
79
+ title: 'Error',
80
+ message: error instanceof Error ? error.message : 'Failed to login',
81
+ color: 'red',
82
+ });
83
+ } finally {
84
+ setLoading(false);
85
+ }
86
+ };
87
+
88
+ return (
89
+ <Box
90
+ style={{
91
+ position: 'fixed',
92
+ top: 0,
93
+ left: 0,
94
+ right: 0,
95
+ bottom: 0,
96
+ display: 'flex',
97
+ alignItems: 'center',
98
+ justifyContent: 'center',
99
+ background: 'linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%)',
100
+ }}
101
+ >
102
+ <Container size={420}>
103
+ <Title ta="center" mb="md">
104
+ Welcome back
105
+ </Title>
106
+ <Text c="dimmed" size="sm" ta="center" mb="xl">
107
+ Sign in to your account
108
+ </Text>
109
+
110
+ <Paper withBorder shadow="md" p={30} radius="md">
111
+ <form onSubmit={form.onSubmit(handleLogin)}>
112
+ <Stack>
113
+ <TextInput
114
+ label="Email"
115
+ placeholder="you@example.com"
116
+ required
117
+ {...form.getInputProps('email')}
118
+ />
119
+
120
+ <PasswordInput
121
+ label="Password"
122
+ placeholder="Your password"
123
+ required
124
+ {...form.getInputProps('password')}
125
+ />
126
+
127
+ <Group justify="flex-end">
128
+ <Anchor component="button" type="button" c="dimmed" size="xs">
129
+ Forgot password?
130
+ </Anchor>
131
+ </Group>
132
+
133
+ <Button type="submit" fullWidth loading={loading}>
134
+ Sign in
135
+ </Button>
136
+ </Stack>
137
+ </form>
138
+ </Paper>
139
+ </Container>
140
+ </Box>
141
+ );
142
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Relations API Route
3
+ *
4
+ * Proxies relation schema requests to the DaaS backend.
5
+ * Required for M2O, M2M, O2M, and M2A relation components.
6
+ *
7
+ * @microbuild/origin: api-routes/relations
8
+ * @microbuild/version: 1.0.0
9
+ */
10
+
11
+ import { NextRequest, NextResponse } from 'next/server';
12
+ import { getAuthHeaders, getDaasUrl } from '@/lib/api/auth-headers';
13
+
14
+ /**
15
+ * GET /api/relations
16
+ * Get all relation definitions
17
+ */
18
+ export async function GET(request: NextRequest) {
19
+ try {
20
+ const headers = await getAuthHeaders();
21
+ const daasUrl = getDaasUrl();
22
+
23
+ // Forward query parameters (e.g., filter by collection)
24
+ const searchParams = request.nextUrl.searchParams.toString();
25
+ const url = `${daasUrl}/api/relations${searchParams ? `?${searchParams}` : ''}`;
26
+
27
+ const response = await fetch(url, {
28
+ headers,
29
+ cache: 'no-store',
30
+ });
31
+
32
+ if (!response.ok) {
33
+ const error = await response.json().catch(() => ({ errors: [{ message: 'Request failed' }] }));
34
+ return NextResponse.json(error, { status: response.status });
35
+ }
36
+
37
+ const data = await response.json();
38
+ return NextResponse.json(data);
39
+ } catch (error) {
40
+ console.error('Relations API error:', error);
41
+ return NextResponse.json(
42
+ { errors: [{ message: error instanceof Error ? error.message : 'Internal server error' }] },
43
+ { status: 500 }
44
+ );
45
+ }
46
+ }
@@ -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: "Microbuild 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}>Microbuild 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 Microbuild 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
+ * Microbuild Utilities
3
+ *
4
+ * Shared utility functions for Microbuild 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
+ }