Small stuff

This commit is contained in:
Gabriel Brown 2025-05-22 18:31:49 -05:00
parent 8169c719f6
commit 7f78bc7123
9 changed files with 238 additions and 48 deletions

View File

@ -9,7 +9,7 @@ This is my template for self hosting both Next.js & Supabase in order to create
- Clone this repo.
- Go to src/server/db/schema.sql & run this SQL in the SQL editor on the Web UI of your Supabase instance.
- Generate your types
- This part is potentially super weird if you are self hosting. If you are connecting directly to your database that you plan to use for production, you will need to clone your repo on the host running supabase so that you can then use the supabase cli tool. Once you have done that, you will need to install the supabase-cli tool with sudo. I just run something like `sudo npx supabase --help` and then accept the prompt to install the program. Once you have done this, you can then run the following command, replacing the password and the port to match your supabase database:
- This part is potentially super weird if you are self hosting. If you are connecting directly to your database that you plan to use for production, you will need to clone your repo on the host running supabase so that you can then use the supabase cli tool. Once you have done that, you will need to install the supabase-cli tool with sudo. I just run something like `sudo npx supabase --help` and then accept the prompt to install the program. Once you have done this, you can then run the following command, replacing the password and the port to match your supabase database. You can also try running the provided script `./scripts/generate_types`
```bash
sudo npx supabase gen types typescript \

View File

@ -103,6 +103,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarResponse.success) {
@ -138,7 +139,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');

View File

@ -1,7 +1,6 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import {
Avatar,
AvatarFallback,
@ -24,23 +23,25 @@ const AvatarDropdown = () => {
await signOut();
};
const getInitials = (name: string | null | undefined): string => {
if (!name) return '';
return name.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar className='cursor-pointer'>
{avatarUrl ? (
<AvatarImage src={avatarUrl} />
<AvatarImage src={avatarUrl} alt={getInitials(profile?.full_name)} width={64} height={64} />
) : (
<AvatarFallback className='text-2xl'>
{profile?.full_name ? (
profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
) : (
<User size={32} />
)}
<AvatarFallback className='text-sm'>
{profile?.full_name
? getInitials(profile.full_name)
: <User size={32} />}
</AvatarFallback>
)}
</Avatar>

View File

@ -19,7 +19,16 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
const file = e.target.files?.[0];
if (!file) return;
const result = await uploadToStorage(file, 'avatars');
const result = await uploadToStorage({
file,
bucket: 'avatars',
resize: true,
options: {
maxWidth: 500,
maxHeight: 500,
quality: 0.8,
}
});
if (result.success && result.path) {
await onAvatarUploaded(result.path);
}
@ -41,9 +50,9 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
>
<Avatar className='h-32 w-32'>
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt={profile?.full_name ?? 'User'} />
<AvatarImage src={avatarUrl} alt={getInitials(profile?.full_name)} width={128} height={128} />
) : (
<AvatarFallback className='text-2xl'>
<AvatarFallback className='text-4xl'>
{profile?.full_name
? getInitials(profile.full_name)
: <User size={32} />}

View File

@ -14,7 +14,7 @@ export type GetStorageProps = {
format?: 'origin';
resize?: 'cover' | 'contain' | 'fill';
};
download?: boolean;
download?: boolean | string;
};
export type UploadStorageProps = {
@ -28,28 +28,46 @@ export type UploadStorageProps = {
};
};
export async function getSignedUrl({
export type ReplaceStorageProps = {
bucket: string;
prevPath: string;
path: string;
file: File;
options?: {
cacheControl?: string;
upsert?: boolean;
contentType?: string;
};
};
export type resizeImageProps = {
file: File,
options?: {
maxWidth?: number,
maxHeight?: number,
quality?: number,
}
};
export const getSignedUrl = async ({
bucket,
url,
seconds = 3600,
transform,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> {
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(url, seconds, { transform });
.createSignedUrl(url, seconds, {
download,
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) {
return {
@ -62,27 +80,23 @@ export async function getSignedUrl({
}
}
export async function getPublicUrl({
export const getPublicUrl = async ({
bucket,
url,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> {
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data } = supabase.storage
.from(bucket)
.getPublicUrl(url, { transform });
.getPublicUrl(url, {
download,
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) {
return {
@ -93,14 +107,14 @@ export async function getPublicUrl({
: 'Unknown error getting public URL',
};
}
}
}
export async function uploadFile({
export const uploadFile = async ({
bucket,
path,
file,
options = {},
}: UploadStorageProps): Promise<Result<string>> {
}: UploadStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
@ -120,14 +134,42 @@ export async function uploadFile({
}
}
export const replaceFile = async ({
bucket,
prevPath,
path,
file,
options = {},
}: ReplaceStorageProps): Promise<Result<string>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
.from(bucket)
.update(path, file, options);
if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload');
const deleteFileData = await deleteFile({
bucket,
path: [...prevPath],
});
return { success: true, data: data.path };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error replacing file',
};
}
};
// Add a helper to delete files
export async function deleteFile({
export const deleteFile = async ({
bucket,
path,
}: {
bucket: string;
path: string[];
}): Promise<Result<null>> {
}): Promise<Result<null>> => {
try {
const supabase = await createServerClient();
const { error } = await supabase.storage.from(bucket).remove(path);
@ -145,7 +187,7 @@ export async function deleteFile({
}
// Add a helper to list files in a bucket
export async function listFiles({
export const listFiles = async ({
bucket,
path = '',
options = {},
@ -157,7 +199,7 @@ export async function listFiles({
offset?: number;
sortBy?: { column: string; order: 'asc' | 'desc' };
};
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> {
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
try {
const supabase = await createServerClient();
const { data, error } = await supabase.storage
@ -177,3 +219,52 @@ export async function listFiles({
};
}
}
export const resizeImage = async ({
file,
options = {},
}: resizeImageProps): Promise<File> => {
const {
maxWidth = 800,
maxHeight = 800,
quality = 0.8,
} = options;
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth / width));
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((width * maxHeight / height));
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const resizedFile = new File([blob], file.name, {
type: 'imgage/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
},
'image/jpeg',
quality
);
};
};
});
};

2
src/lib/hooks/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './resizeImage';
export * from './useFileUpload';

View File

@ -0,0 +1,59 @@
'use client'
export type resizeImageProps = {
file: File,
options?: {
maxWidth?: number,
maxHeight?: number,
quality?: number,
}
};
export const resizeImage = async ({
file,
options = {},
}: resizeImageProps): Promise<File> => {
const {
maxWidth = 800,
maxHeight = 800,
quality = 0.8,
} = options;
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth / width));
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((width * maxHeight / height));
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const resizedFile = new File([blob], file.name, {
type: 'imgage/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
},
'image/jpeg',
quality
);
};
};
});
};

View File

@ -1,24 +1,51 @@
'use client'
import { useState, useRef } from 'react';
import { uploadFile } from '@/lib/actions';
import { toast } from 'sonner';
import { useAuth } from '@/components/context/auth';
import { resizeImage } from '@/lib/hooks';
export type uploadToStorageProps = {
file: File;
bucket: string;
resize: boolean;
options?: {
maxWidth?: number;
maxHeight?: number;
quality?: number;
}
};
export const useFileUpload = () => {
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { profile, isAuthenticated } = useAuth();
const uploadToStorage = async (file: File, bucket: string) => {
const uploadToStorage = async ({
file,
bucket,
resize = false,
options = {},
}: uploadToStorageProps) => {
try {
if (!isAuthenticated) throw new Error('User is not authenticated');
setIsUploading(true);
let fileToUpload = file;
if (resize && file.type.startsWith('image/'))
fileToUpload = await resizeImage({ file, options });
// Generate a unique filename to avoid collisions
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}.${fileExt}`;
const fileName = `${Date.now()}-${profile?.id}.${fileExt}`;
// Upload the file to Supabase storage
const uploadResult = await uploadFile({
bucket,
path: fileName,
file,
file: fileToUpload,
options: {
upsert: true,
contentType: file.type,