@thinhnguyencth1204/nextcli 0.7.0 → 0.9.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 (114) hide show
  1. package/README.md +37 -24
  2. package/dist/cli.js +168 -107
  3. package/package.json +5 -3
  4. package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
  5. package/templates/next-base/.env +16 -0
  6. package/templates/next-base/.env.development +16 -0
  7. package/templates/next-base/.env.example +16 -0
  8. package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
  9. package/templates/next-base/SETUP.md +62 -10
  10. package/templates/next-base/bun.lock +59 -414
  11. package/templates/next-base/messages/vi/auth.json +42 -0
  12. package/templates/next-base/messages/vi/common.json +34 -0
  13. package/templates/next-base/messages/vi/example.json +10 -0
  14. package/templates/next-base/next-env.d.ts +1 -1
  15. package/templates/next-base/next.config.ts +4 -1
  16. package/templates/next-base/nextcli.json +12 -4
  17. package/templates/next-base/package.json +25 -1
  18. package/templates/next-base/prisma/schema.prisma +84 -0
  19. package/templates/next-base/prisma.config.ts +16 -0
  20. package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
  21. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  22. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  23. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  25. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
  26. package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
  27. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  28. package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
  29. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  30. package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  31. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  32. package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
  33. package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
  34. package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
  35. package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
  36. package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
  37. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  38. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  39. package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
  40. package/templates/next-base/src/app/globals.css +57 -0
  41. package/templates/next-base/src/app/layout.tsx +14 -6
  42. package/templates/next-base/src/app/page.tsx +2 -25
  43. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  44. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  45. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  46. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  47. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  48. package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
  49. package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
  50. package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
  51. package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
  52. package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
  53. package/templates/next-base/src/components/rich-text/index.ts +11 -0
  54. package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
  55. package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
  56. package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
  57. package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
  58. package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
  59. package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
  60. package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
  61. package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
  62. package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
  63. package/templates/next-base/src/components/rich-text/types.ts +60 -0
  64. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  65. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  66. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  67. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  68. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  69. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  70. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  71. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  72. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  73. package/templates/next-base/src/example/api/use-example.ts +21 -0
  74. package/templates/next-base/src/example/api/use-mutations.ts +20 -0
  75. package/templates/next-base/src/example/components/example-table.tsx +51 -0
  76. package/templates/next-base/src/example/services.ts +9 -0
  77. package/templates/next-base/src/example/validations.ts +8 -0
  78. package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
  79. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  80. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
  81. package/templates/next-base/src/features/auth/validations.ts +14 -0
  82. package/templates/next-base/src/features/users/services.ts +132 -0
  83. package/templates/next-base/src/features/users/validations.ts +21 -0
  84. package/templates/next-base/src/hooks/index.ts +1 -1
  85. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  86. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  87. package/templates/next-base/src/i18n/config.ts +7 -0
  88. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  89. package/templates/next-base/src/i18n/request.ts +25 -0
  90. package/templates/next-base/src/instrumentation.ts +14 -0
  91. package/templates/next-base/src/lib/api/axios.ts +145 -0
  92. package/templates/next-base/src/lib/api/response.ts +45 -0
  93. package/templates/next-base/src/lib/api/token-store.ts +13 -0
  94. package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
  95. package/templates/next-base/src/lib/auth/client.ts +7 -0
  96. package/templates/next-base/src/lib/auth/cookies.ts +15 -0
  97. package/templates/next-base/src/lib/auth/index.ts +1 -0
  98. package/templates/next-base/src/lib/auth/rbac.ts +59 -0
  99. package/templates/next-base/src/lib/auth/server.ts +21 -0
  100. package/templates/next-base/src/lib/constants.ts +10 -0
  101. package/templates/next-base/src/lib/db/prisma.ts +23 -0
  102. package/templates/next-base/src/lib/prisma.ts +23 -0
  103. package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
  104. package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
  105. package/templates/next-base/src/lib/rich-text/index.ts +12 -0
  106. package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
  107. package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
  108. package/templates/next-base/src/lib/supabase/client.ts +6 -0
  109. package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
  110. package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
  111. package/templates/next-base/src/lib/supabase/storage.ts +164 -0
  112. package/templates/next-base/src/types/data-table.ts +4 -0
  113. package/templates/next-base/src/types/index.ts +0 -2
  114. package/templates/next-base/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,23 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg";
2
+ import { PrismaClient } from "@prisma/client";
3
+ import { Pool } from "pg";
4
+
5
+ declare global {
6
+ var prisma: PrismaClient | undefined;
7
+ }
8
+
9
+ const connectionString = process.env.DATABASE_URL;
10
+ if (!connectionString) {
11
+ throw new Error(
12
+ "DATABASE_URL is missing. Please set it in your environment.",
13
+ );
14
+ }
15
+
16
+ const adapter = new PrismaPg(new Pool({ connectionString }));
17
+ const prisma = global.prisma ?? new PrismaClient({ adapter });
18
+
19
+ if (process.env.NODE_ENV !== "production") {
20
+ global.prisma = prisma;
21
+ }
22
+
23
+ export default prisma;
@@ -0,0 +1,23 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg";
2
+ import { PrismaClient } from "@prisma/client";
3
+ import { Pool } from "pg";
4
+
5
+ declare global {
6
+ var prisma: PrismaClient | undefined;
7
+ }
8
+
9
+ const connectionString = process.env.DATABASE_URL;
10
+ if (!connectionString) {
11
+ throw new Error(
12
+ "DATABASE_URL is missing. Please set it in your environment.",
13
+ );
14
+ }
15
+
16
+ const adapter = new PrismaPg(new Pool({ connectionString }));
17
+ const prisma = global.prisma ?? new PrismaClient({ adapter });
18
+
19
+ if (process.env.NODE_ENV !== "production") {
20
+ global.prisma = prisma;
21
+ }
22
+
23
+ export default prisma;
@@ -0,0 +1,10 @@
1
+ import type { RemoveManagedImageFn } from "@/lib/rich-text";
2
+
3
+ /**
4
+ * Default no-op image remover for base-only projects.
5
+ * After `nextcli add module supabase`, use
6
+ * `removeSupabaseManagedImage` from `@/lib/supabase/rich-text-image-sync`.
7
+ */
8
+ export const defaultRemoveManagedImage: RemoveManagedImageFn = async () => {
9
+ // Intentionally empty — wire Supabase removal when the module is installed.
10
+ };
@@ -0,0 +1,41 @@
1
+ import type { SerializedEditorState } from "lexical";
2
+
3
+ type LexicalJsonNode = {
4
+ type?: string;
5
+ src?: string;
6
+ children?: LexicalJsonNode[];
7
+ };
8
+
9
+ function walkNodes(node: LexicalJsonNode, urls: Set<string>): void {
10
+ if (node.type === "image" && typeof node.src === "string" && node.src) {
11
+ urls.add(node.src);
12
+ }
13
+
14
+ if (Array.isArray(node.children)) {
15
+ for (const child of node.children) {
16
+ walkNodes(child, urls);
17
+ }
18
+ }
19
+ }
20
+
21
+ export function extractImageUrlsFromState(
22
+ state: SerializedEditorState | null | undefined,
23
+ ): string[] {
24
+ if (!state?.root) {
25
+ return [];
26
+ }
27
+
28
+ const urls = new Set<string>();
29
+ walkNodes(state.root as LexicalJsonNode, urls);
30
+ return [...urls];
31
+ }
32
+
33
+ export function diffRemovedImageUrls(
34
+ previous: SerializedEditorState | null | undefined,
35
+ next: SerializedEditorState | null | undefined,
36
+ ): string[] {
37
+ const previousUrls = extractImageUrlsFromState(previous);
38
+ const nextUrls = new Set(extractImageUrlsFromState(next));
39
+
40
+ return previousUrls.filter((url) => !nextUrls.has(url));
41
+ }
@@ -0,0 +1,12 @@
1
+ export { diffRemovedImageUrls, extractImageUrlsFromState } from "./image-urls";
2
+ export {
3
+ isSupabaseManagedImageUrl,
4
+ parseSupabaseStorageUrl,
5
+ type SupabaseStorageRef,
6
+ } from "./supabase-url";
7
+ export {
8
+ syncRemovedRichTextImages,
9
+ type RemoveManagedImageFn,
10
+ type SyncRemovedImagesOptions,
11
+ } from "./sync-removed-images";
12
+ export { defaultRemoveManagedImage } from "./default-image-removal";
@@ -0,0 +1,67 @@
1
+ const DEFAULT_BUCKET =
2
+ process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET?.trim() || "public";
3
+
4
+ export type SupabaseStorageRef = {
5
+ bucket: string;
6
+ path: string;
7
+ };
8
+
9
+ function getConfiguredBucket(): string {
10
+ return (
11
+ process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET?.trim() || DEFAULT_BUCKET
12
+ );
13
+ }
14
+
15
+ /**
16
+ * Maps a Supabase public object URL back to `{ bucket, path }`.
17
+ * Returns null for external URLs or when Supabase env is not configured.
18
+ */
19
+ export function parseSupabaseStorageUrl(
20
+ url: string,
21
+ ): SupabaseStorageRef | null {
22
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
23
+ if (!supabaseUrl) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const parsed = new URL(url);
29
+ const base = new URL(supabaseUrl);
30
+
31
+ if (parsed.origin !== base.origin) {
32
+ return null;
33
+ }
34
+
35
+ const publicPrefix = "/storage/v1/object/public/";
36
+ if (!parsed.pathname.startsWith(publicPrefix)) {
37
+ return null;
38
+ }
39
+
40
+ const remainder = parsed.pathname.slice(publicPrefix.length);
41
+ const slashIndex = remainder.indexOf("/");
42
+ if (slashIndex <= 0) {
43
+ return null;
44
+ }
45
+
46
+ const bucket = remainder.slice(0, slashIndex);
47
+ const path = remainder.slice(slashIndex + 1);
48
+
49
+ if (!path) {
50
+ return null;
51
+ }
52
+
53
+ return { bucket, path };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function isSupabaseManagedImageUrl(url: string): boolean {
60
+ const ref = parseSupabaseStorageUrl(url);
61
+ if (!ref) {
62
+ return false;
63
+ }
64
+
65
+ const configuredBucket = getConfiguredBucket();
66
+ return ref.bucket === configuredBucket;
67
+ }
@@ -0,0 +1,48 @@
1
+ import type { SerializedEditorState } from "lexical";
2
+ import { diffRemovedImageUrls } from "./image-urls";
3
+ import {
4
+ isSupabaseManagedImageUrl,
5
+ parseSupabaseStorageUrl,
6
+ } from "./supabase-url";
7
+
8
+ export type RemoveManagedImageFn = (
9
+ url: string,
10
+ ref: { bucket: string; path: string },
11
+ ) => Promise<void>;
12
+
13
+ export type SyncRemovedImagesOptions = {
14
+ /** Custom predicate; defaults to Supabase bucket/path URLs for this project. */
15
+ isManagedUrl?: (url: string) => boolean;
16
+ /** Injected remover (e.g. Supabase `removeResource`). */
17
+ removeManagedImage: RemoveManagedImageFn;
18
+ };
19
+
20
+ /**
21
+ * Compares previous and next Lexical states, then removes storage objects
22
+ * for images that disappeared from content. Non-managed URLs are ignored.
23
+ */
24
+ export async function syncRemovedRichTextImages(
25
+ previous: SerializedEditorState | null | undefined,
26
+ next: SerializedEditorState | null | undefined,
27
+ options: SyncRemovedImagesOptions,
28
+ ): Promise<string[]> {
29
+ const removedUrls = diffRemovedImageUrls(previous, next);
30
+ const isManaged = options.isManagedUrl ?? isSupabaseManagedImageUrl;
31
+ const deleted: string[] = [];
32
+
33
+ for (const url of removedUrls) {
34
+ if (!isManaged(url)) {
35
+ continue;
36
+ }
37
+
38
+ const ref = parseSupabaseStorageUrl(url);
39
+ if (!ref) {
40
+ continue;
41
+ }
42
+
43
+ await options.removeManagedImage(url, ref);
44
+ deleted.push(url);
45
+ }
46
+
47
+ return deleted;
48
+ }
@@ -0,0 +1,6 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+
3
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
4
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
5
+
6
+ export const supabase = url && anonKey ? createClient(url, anonKey) : null;
@@ -0,0 +1,28 @@
1
+ import { removeResource } from "@/lib/supabase/storage";
2
+ import {
3
+ isSupabaseManagedImageUrl,
4
+ syncRemovedRichTextImages,
5
+ type RemoveManagedImageFn,
6
+ } from "@/lib/rich-text";
7
+ import type { SerializedEditorState } from "lexical";
8
+
9
+ export const removeSupabaseManagedImage: RemoveManagedImageFn = async (
10
+ _url,
11
+ ref,
12
+ ) => {
13
+ await removeResource(ref.path, ref.bucket);
14
+ };
15
+
16
+ /**
17
+ * Deletes Supabase storage objects for images removed from rich text content.
18
+ * Only URLs belonging to this project's configured Supabase bucket are removed.
19
+ */
20
+ export async function syncRemovedSupabaseRichTextImages(
21
+ previous: SerializedEditorState | null | undefined,
22
+ next: SerializedEditorState | null | undefined,
23
+ ) {
24
+ return syncRemovedRichTextImages(previous, next, {
25
+ isManagedUrl: isSupabaseManagedImageUrl,
26
+ removeManagedImage: removeSupabaseManagedImage,
27
+ });
28
+ }
@@ -0,0 +1,69 @@
1
+ export const DEFAULT_STORAGE_BUCKET =
2
+ process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET?.trim() || "public";
3
+
4
+ export const STORAGE_RESOURCE_CATEGORIES = [
5
+ "images",
6
+ "documents",
7
+ "avatars",
8
+ "misc",
9
+ ] as const;
10
+
11
+ export type StorageResourceCategory =
12
+ (typeof STORAGE_RESOURCE_CATEGORIES)[number];
13
+
14
+ const CATEGORY_DEFAULTS: Record<
15
+ StorageResourceCategory,
16
+ { maxBytes: number; allowedMimePrefixes: string[] }
17
+ > = {
18
+ images: { maxBytes: 5 * 1024 * 1024, allowedMimePrefixes: ["image/"] },
19
+ documents: {
20
+ maxBytes: 10 * 1024 * 1024,
21
+ allowedMimePrefixes: [
22
+ "application/pdf",
23
+ "application/msword",
24
+ "application/vnd.",
25
+ "text/",
26
+ ],
27
+ },
28
+ avatars: { maxBytes: 2 * 1024 * 1024, allowedMimePrefixes: ["image/"] },
29
+ misc: { maxBytes: 10 * 1024 * 1024, allowedMimePrefixes: [] },
30
+ };
31
+
32
+ export function getStorageCategoryDefaults(category: StorageResourceCategory) {
33
+ return CATEGORY_DEFAULTS[category];
34
+ }
35
+
36
+ export function sanitizeStorageFileName(fileName: string): string {
37
+ const base = fileName.split(/[/\\]/).pop() ?? "file";
38
+ const sanitized = base
39
+ .normalize("NFKD")
40
+ .replace(/[^\w.\-]+/g, "-")
41
+ .replace(/-+/g, "-")
42
+ .replace(/^-+|-+$/g, "")
43
+ .slice(0, 120);
44
+
45
+ return sanitized || "file";
46
+ }
47
+
48
+ function sanitizeStorageScope(scope: string): string {
49
+ const sanitized = scope
50
+ .trim()
51
+ .replace(/[^a-zA-Z0-9_-]/g, "-")
52
+ .replace(/-+/g, "-")
53
+ .slice(0, 64);
54
+
55
+ return sanitized || "shared";
56
+ }
57
+
58
+ export function buildStorageObjectPath(input: {
59
+ scope: string;
60
+ category: StorageResourceCategory;
61
+ fileName: string;
62
+ uniqueId?: string;
63
+ }): string {
64
+ const scope = sanitizeStorageScope(input.scope);
65
+ const uniqueId = input.uniqueId ?? crypto.randomUUID();
66
+ const safeName = sanitizeStorageFileName(input.fileName);
67
+
68
+ return `${scope}/${input.category}/${uniqueId}-${safeName}`;
69
+ }
@@ -0,0 +1,164 @@
1
+ import { supabase } from "@/lib/supabase/client";
2
+ import {
3
+ DEFAULT_STORAGE_BUCKET,
4
+ buildStorageObjectPath,
5
+ getStorageCategoryDefaults,
6
+ type StorageResourceCategory,
7
+ } from "@/lib/supabase/storage-config";
8
+
9
+ export type UploadResourceInput = {
10
+ file: File | Blob;
11
+ fileName: string;
12
+ /** Tenant/user/feature namespace, e.g. user id or `shared`. */
13
+ scope: string;
14
+ category?: StorageResourceCategory;
15
+ bucket?: string;
16
+ upsert?: boolean;
17
+ cacheControl?: string;
18
+ contentType?: string;
19
+ };
20
+
21
+ export type UploadResourceResult = {
22
+ bucket: string;
23
+ path: string;
24
+ publicUrl: string;
25
+ };
26
+
27
+ export class StorageHelperError extends Error {
28
+ constructor(
29
+ message: string,
30
+ readonly code:
31
+ | "NOT_CONFIGURED"
32
+ | "VALIDATION"
33
+ | "UPLOAD_FAILED"
34
+ | "REMOVE_FAILED",
35
+ readonly cause?: unknown,
36
+ ) {
37
+ super(message);
38
+ this.name = "StorageHelperError";
39
+ }
40
+ }
41
+
42
+ function getClient() {
43
+ if (!supabase) {
44
+ throw new StorageHelperError(
45
+ "Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.",
46
+ "NOT_CONFIGURED",
47
+ );
48
+ }
49
+
50
+ return supabase;
51
+ }
52
+
53
+ function resolveContentType(
54
+ file: File | Blob,
55
+ override?: string,
56
+ ): string | undefined {
57
+ if (override) {
58
+ return override;
59
+ }
60
+
61
+ if (file instanceof File && file.type) {
62
+ return file.type;
63
+ }
64
+
65
+ return undefined;
66
+ }
67
+
68
+ function assertCategoryRules(
69
+ category: StorageResourceCategory,
70
+ file: File | Blob,
71
+ contentType?: string,
72
+ ): void {
73
+ const { maxBytes, allowedMimePrefixes } =
74
+ getStorageCategoryDefaults(category);
75
+
76
+ if (file.size > maxBytes) {
77
+ throw new StorageHelperError(
78
+ `File exceeds ${category} limit of ${maxBytes} bytes.`,
79
+ "VALIDATION",
80
+ );
81
+ }
82
+
83
+ if (allowedMimePrefixes.length === 0) {
84
+ return;
85
+ }
86
+
87
+ const mime = contentType ?? (file instanceof File ? file.type : "");
88
+
89
+ if (!mime || !allowedMimePrefixes.some((prefix) => mime.startsWith(prefix))) {
90
+ throw new StorageHelperError(
91
+ `File type "${mime || "unknown"}" is not allowed for category "${category}".`,
92
+ "VALIDATION",
93
+ );
94
+ }
95
+ }
96
+
97
+ export function getResourcePublicUrl(
98
+ path: string,
99
+ bucket: string = DEFAULT_STORAGE_BUCKET,
100
+ ): string {
101
+ const client = getClient();
102
+ const { data } = client.storage.from(bucket).getPublicUrl(path);
103
+
104
+ return data.publicUrl;
105
+ }
106
+
107
+ export async function uploadResource(
108
+ input: UploadResourceInput,
109
+ ): Promise<UploadResourceResult> {
110
+ const client = getClient();
111
+ const bucket = input.bucket ?? DEFAULT_STORAGE_BUCKET;
112
+ const category = input.category ?? "images";
113
+ const contentType = resolveContentType(input.file, input.contentType);
114
+
115
+ assertCategoryRules(category, input.file, contentType);
116
+
117
+ const path = buildStorageObjectPath({
118
+ scope: input.scope,
119
+ category,
120
+ fileName: input.fileName,
121
+ });
122
+
123
+ const { error } = await client.storage.from(bucket).upload(path, input.file, {
124
+ contentType,
125
+ cacheControl: input.cacheControl ?? "3600",
126
+ upsert: input.upsert ?? false,
127
+ });
128
+
129
+ if (error) {
130
+ throw new StorageHelperError(
131
+ error.message || "Upload failed.",
132
+ "UPLOAD_FAILED",
133
+ error,
134
+ );
135
+ }
136
+
137
+ return {
138
+ bucket,
139
+ path,
140
+ publicUrl: getResourcePublicUrl(path, bucket),
141
+ };
142
+ }
143
+
144
+ export async function uploadImage(
145
+ input: Omit<UploadResourceInput, "category">,
146
+ ): Promise<UploadResourceResult> {
147
+ return uploadResource({ ...input, category: "images" });
148
+ }
149
+
150
+ export async function removeResource(
151
+ path: string,
152
+ bucket: string = DEFAULT_STORAGE_BUCKET,
153
+ ): Promise<void> {
154
+ const client = getClient();
155
+ const { error } = await client.storage.from(bucket).remove([path]);
156
+
157
+ if (error) {
158
+ throw new StorageHelperError(
159
+ error.message || "Remove failed.",
160
+ "REMOVE_FAILED",
161
+ error,
162
+ );
163
+ }
164
+ }
@@ -0,0 +1,4 @@
1
+ export type DataTablePaginationState = {
2
+ pageIndex: number;
3
+ pageSize: number;
4
+ };
@@ -38,5 +38,3 @@ export type ApiErrorResponse = {
38
38
  };
39
39
 
40
40
  export type ApiResponse<T> = ApiSuccess<T> | ApiErrorResponse;
41
-
42
- export * from "@/types/data-table";