diff --git a/package.json b/package.json index 61a7bf4..8884c54 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@tailwindcss/postcss": "^4.1.6", - "@types/node": "^20.17.46", + "@types/node": "^20.17.47", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "eslint": "^9.26.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac8e525..a1c7cd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,8 +58,8 @@ importers: specifier: ^4.1.6 version: 4.1.6 '@types/node': - specifier: ^20.17.46 - version: 20.17.46 + specifier: ^20.17.47 + version: 20.17.47 '@types/react': specifier: ^19.1.4 version: 19.1.4 @@ -680,8 +680,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@20.17.46': - resolution: {integrity: sha512-0PQHLhZPWOxGW4auogW0eOQAuNIlCYvibIpG67ja0TOJ6/sehu+1en7sfceUn+QQtx4Rk3GxbLNwPh0Cav7TWw==} + '@types/node@20.17.47': + resolution: {integrity: sha512-3dLX0Upo1v7RvUimvxLeXqwrfyKxUINk0EAM83swP2mlSUcwV73sZy8XhNz8bcZ3VbsfQyC/y6jRdL5tgCNpDQ==} '@types/phoenix@1.6.6': resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} @@ -1041,8 +1041,8 @@ packages: supports-color: optional: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -2378,7 +2378,7 @@ snapshots: '@eslint/config-array@0.20.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0 + debug: 4.4.1 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -2392,7 +2392,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0 + debug: 4.4.1 espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 @@ -2841,7 +2841,7 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@20.17.46': + '@types/node@20.17.47': dependencies: undici-types: 6.19.8 @@ -2857,7 +2857,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 20.17.46 + '@types/node': 20.17.47 '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.26.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.26.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: @@ -2882,7 +2882,7 @@ snapshots: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.32.1 - debug: 4.4.0 + debug: 4.4.1 eslint: 9.26.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: @@ -2897,7 +2897,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) '@typescript-eslint/utils': 8.32.1(eslint@9.26.0(jiti@2.4.2))(typescript@5.8.3) - debug: 4.4.0 + debug: 4.4.1 eslint: 9.26.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 @@ -2910,7 +2910,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/visitor-keys': 8.32.1 - debug: 4.4.0 + debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -3098,7 +3098,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.0 + debug: 4.4.1 http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -3232,7 +3232,7 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0: + debug@4.4.1: dependencies: ms: 2.1.3 @@ -3408,7 +3408,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.26.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.0 + debug: 4.4.1 eslint: 9.26.0(jiti@2.4.2) get-tsconfig: 4.10.0 is-bun-module: 2.0.0 @@ -3533,7 +3533,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0 + debug: 4.4.1 escape-string-regexp: 4.0.0 eslint-scope: 8.3.0 eslint-visitor-keys: 4.2.0 @@ -3596,7 +3596,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.0 + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -3660,7 +3660,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.0 + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -4307,7 +4307,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.0 + debug: 4.4.1 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -4350,7 +4350,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.0 + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 diff --git a/src/actions/image.ts b/src/actions/image.ts deleted file mode 100644 index e5368d3..0000000 --- a/src/actions/image.ts +++ /dev/null @@ -1,4 +0,0 @@ -'use server'; - -import 'server-only'; -import { createServerClient } from '@/utils/supabase'; diff --git a/src/app/(auth-pages)/forgot-password/page.tsx b/src/app/(auth-pages)/forgot-password/page.tsx index 443cadf..d197eff 100644 --- a/src/app/(auth-pages)/forgot-password/page.tsx +++ b/src/app/(auth-pages)/forgot-password/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { forgotPassword } from '@/actions/auth'; +import { forgotPassword } from '@/lib/actions'; import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { Input, Label } from '@/components/ui'; import { SmtpMessage } from '@/app/(auth-pages)/smtp-message'; diff --git a/src/app/(auth-pages)/sign-in/page.tsx b/src/app/(auth-pages)/sign-in/page.tsx index b61d3a6..2889e63 100644 --- a/src/app/(auth-pages)/sign-in/page.tsx +++ b/src/app/(auth-pages)/sign-in/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { signIn } from '@/actions/auth'; +import { signIn } from '@/lib/actions'; import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { Input, Label } from '@/components/ui'; diff --git a/src/app/(auth-pages)/sign-up/page.tsx b/src/app/(auth-pages)/sign-up/page.tsx index 952e509..e96ec2e 100644 --- a/src/app/(auth-pages)/sign-up/page.tsx +++ b/src/app/(auth-pages)/sign-up/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { signUp } from '@/actions/auth'; +import { signUp } from '@/lib/actions'; import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { Input, Label } from '@/components/ui'; import { SmtpMessage } from '@/app/(auth-pages)/smtp-message'; diff --git a/src/app/protected/reset-password/page.tsx b/src/app/protected/reset-password/page.tsx index 5588fd9..4ead72d 100644 --- a/src/app/protected/reset-password/page.tsx +++ b/src/app/protected/reset-password/page.tsx @@ -1,4 +1,4 @@ -import { resetPassword } from '@/actions/auth'; +import { resetPassword } from '@/lib/actions'; import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { Input, Label } from '@/components/ui'; diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx index ae448a7..9220f9a 100644 --- a/src/app/test/page.tsx +++ b/src/app/test/page.tsx @@ -1,54 +1,21 @@ 'use server'; - -import { createServerClient } from '@/utils/supabase'; +import { getSignedUrl, getProfile } from '@/lib/actions'; import Image from 'next/image'; -export default async function Page() { - const supabase = await createServerClient(); +const Page = async () => { + const user = await getProfile(); + if (!user.success) throw new Error(user.error); - // Get authenticated user - const { - data: { user: authUser }, - error: userError, - } = await supabase.auth.getUser(); - - if (userError || !authUser) { - return ( -
- Error loading user: {userError?.message ?? 'User not authenticated'} -
- ); - } - - // Get user profile - const { data: user, error: profileError } = await supabase - .from('profiles') - .select('*') - .eq('id', authUser.id) - .single(); - - if (profileError || !user) { - return ( -
- Error loading profile: {profileError?.message ?? 'Profile not found'} -
- ); - } - - // Check if avatar URL exists - if (!user.avatar_url) { - return
No avatar image available
; - } - - // Get public URL for the avatar - const { data: imageData } = await supabase.storage - .from('avatars') - .createSignedUrl(user.avatar_url, 3600); + const imageUrl = await getSignedUrl({ + bucket: 'avatars', + url: user.data.avatar_url ?? '', + }); + if (!imageUrl.success) throw new Error(imageUrl.error); return (
User avatar
); -} +}; +export default Page; diff --git a/src/components/navigation/auth.tsx b/src/components/navigation/auth.tsx index cd2806e..e86802b 100644 --- a/src/components/navigation/auth.tsx +++ b/src/components/navigation/auth.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui'; import { createServerClient } from '@/utils/supabase'; -import { signOut } from '@/actions/auth'; +import { signOut } from '@/lib/actions'; const NavigationAuth = async () => { const supabase = await createServerClient(); diff --git a/src/actions/auth.ts b/src/lib/actions/auth.ts similarity index 92% rename from src/actions/auth.ts rename to src/lib/actions/auth.ts index 9d1b0da..da7bc8f 100644 --- a/src/actions/auth.ts +++ b/src/lib/actions/auth.ts @@ -136,3 +136,14 @@ export const signOut = async () => { await supabase.auth.signOut(); return redirect('/sign-in'); }; + +export const getUser = async () => { + try { + const supabase = await createServerClient(); + const { data, error } = await supabase.auth.getUser(); + if (error) throw error; + return data.user; + } catch (error) { + console.error('Could not get user!', error); + } +}; diff --git a/src/lib/actions/index.ts b/src/lib/actions/index.ts new file mode 100644 index 0000000..13e5fc8 --- /dev/null +++ b/src/lib/actions/index.ts @@ -0,0 +1,7 @@ +export * from './auth'; +export * from './storage'; +export * from './public'; + +export type Result = + | { success: true; data: T } + | { success: false; error: string }; diff --git a/src/lib/actions/public.ts b/src/lib/actions/public.ts new file mode 100644 index 0000000..5412fca --- /dev/null +++ b/src/lib/actions/public.ts @@ -0,0 +1,30 @@ +'use server'; + +import 'server-only'; +import { createServerClient, type Profile } from '@/utils/supabase'; +import { getUser } from '@/lib/actions'; +import type { Result } from './index'; + +export const getProfile = async (): Promise> => { + try { + const user = await getUser(); + if (!user) throw new Error('User not found'); + const supabase = await createServerClient(); + const { data, error } = await supabase + .from('profiles') + .select('*') + .eq('id', user.id) + .single(); + if (error) throw error; + return { success: true, data: data as Profile }; + } catch (error) { + console.error('Could not get profile!', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Unknown error getting profile', + }; + } +}; diff --git a/src/lib/actions/storage.ts b/src/lib/actions/storage.ts new file mode 100644 index 0000000..30ba8c4 --- /dev/null +++ b/src/lib/actions/storage.ts @@ -0,0 +1,184 @@ +'use server'; +import 'server-only'; +import { createServerClient } from '@/utils/supabase'; +import type { Result } from './index'; + +export type GetStorageProps = { + bucket: string; + url: string; + seconds?: number; + transform?: { + width?: number; + height?: number; + quality?: number; + format?: 'origin'; + resize?: 'cover' | 'contain' | 'fill'; + }; + download?: boolean; +}; + +export type UploadStorageProps = { + bucket: string; + path: string; + file: File; + options?: { + cacheControl?: string; + upsert?: boolean; + contentType?: string; + }; +}; + +export async function getSignedUrl({ + bucket, + url, + seconds = 3600, + transform, + download = false, +}: GetStorageProps): Promise> { + try { + const supabase = await createServerClient(); + const { data, error } = await supabase.storage + .from(bucket) + .createSignedUrl(url, seconds, { transform }); + + if (error) throw error; + if (!data?.signedUrl) throw new Error('No signed URL returned'); + + // Safely add download parameter if needed + if (download) { + const urlObj = new URL(data.signedUrl); + urlObj.searchParams.append('download', ''); + return { success: true, data: urlObj.toString() }; + } + + return { success: true, data: data.signedUrl }; + } catch (error) { + console.error('Could not get signed URL for asset!', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Unknown error getting signed URL', + }; + } +} + +export async function getPublicUrl({ + bucket, + url, + transform = {}, + download = false, +}: GetStorageProps): Promise> { + try { + const supabase = await createServerClient(); + const { data } = supabase.storage + .from(bucket) + .getPublicUrl(url, { transform }); + + if (!data?.publicUrl) throw new Error('No public URL returned'); + + // Safely add download parameter if needed + if (download) { + const urlObj = new URL(data.publicUrl); + urlObj.searchParams.append('download', ''); + return { success: true, data: urlObj.toString() }; + } + + return { success: true, data: data.publicUrl }; + } catch (error) { + console.error('Could not get public URL for asset!', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Unknown error getting public URL', + }; + } +} + +export async function uploadFile({ + bucket, + path, + file, + options = {}, +}: UploadStorageProps): Promise> { + try { + const supabase = await createServerClient(); + const { data, error } = await supabase.storage + .from(bucket) + .upload(path, file, options); + + if (error) throw error; + if (!data?.path) throw new Error('No path returned from upload'); + + return { success: true, data: data.path }; + } catch (error) { + console.error('Could not upload file!', error); + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error uploading file', + }; + } +} + +// Add a helper to delete files +export async function deleteFile({ + bucket, + path, +}: { + bucket: string; + path: string[]; +}): Promise> { + try { + const supabase = await createServerClient(); + const { error } = await supabase.storage.from(bucket).remove(path); + + if (error) throw error; + + return { success: true, data: null }; + } catch (error) { + console.error('Could not delete file!', error); + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error deleting file', + }; + } +} + +// Add a helper to list files in a bucket +export async function listFiles({ + bucket, + path = '', + options = {}, +}: { + bucket: string; + path?: string; + options?: { + limit?: number; + offset?: number; + sortBy?: { column: string; order: 'asc' | 'desc' }; + }; +}): Promise>> { + try { + const supabase = await createServerClient(); + const { data, error } = await supabase.storage + .from(bucket) + .list(path, options); + + if (error) throw error; + if (!data) throw new Error('No data returned from list operation'); + + return { success: true, data }; + } catch (error) { + console.error('Could not list files!', error); + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error listing files', + }; + } +}