Skip to content

Commit 75cd9d6

Browse files
authored
New: avatar by file upload (#157)
* New: avatar by file upload * Format: ok
1 parent 10cf30d commit 75cd9d6

File tree

8 files changed

+364
-71
lines changed

8 files changed

+364
-71
lines changed

.github/workflows/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ updates:
77
- package-ecosystem: "github-actions"
88
directory: "/"
99
schedule:
10-
interval: "monthly"
10+
interval: "monthly"

src/app/(auth)/sign-up/actions.tsx

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,14 @@
11
"use server";
22

33
import { createClient } from "@/lib/supabase/server";
4-
5-
const checkImage = async (url: string) => {
6-
let blob_image = null;
7-
try {
8-
const im = await fetch(url);
9-
blob_image = await im.blob();
10-
} catch {
11-
throw new Error("Image isn't valid");
12-
}
13-
14-
return blob_image.type.startsWith("image/");
15-
};
4+
import { uploadAvatar } from "@/lib/supabase/storage";
165

176
export async function signup(form_data: FormData) {
18-
const name = form_data.get("name").toString();
19-
const surname = form_data.get("surname").toString();
20-
const email = form_data.get("email").toString();
21-
const password = form_data.get("password").toString();
22-
const avatar_url = form_data.get("avatar_url")?.toString();
7+
const name = form_data.get("name")?.toString();
8+
const surname = form_data.get("surname")?.toString();
9+
const email = form_data.get("email")?.toString();
10+
const password = form_data.get("password")?.toString();
11+
const avatarFile = form_data.get("avatar") as File | null;
2312

2413
try {
2514
if (!name || !surname || !email || !password) {
@@ -30,12 +19,6 @@ export async function signup(form_data: FormData) {
3019
"El correo electrónico debe ser de la Pontificia Universidad Javeriana.",
3120
);
3221
}
33-
if (avatar_url) {
34-
const validImage = await checkImage(avatar_url);
35-
if (!validImage) {
36-
throw new Error("El URL del avatar no apunta a una imagen válida.");
37-
}
38-
}
3922

4023
const supabase = await createClient();
4124

@@ -48,12 +31,28 @@ export async function signup(form_data: FormData) {
4831
throw new Error(error.message);
4932
}
5033

34+
if (!data.user?.id) {
35+
throw new Error("Error al crear el usuario.");
36+
}
37+
38+
// Subir avatar si se proporcionó
39+
let avatarUrl: string | null = null;
40+
if (avatarFile && avatarFile.size > 0) {
41+
try {
42+
avatarUrl = await uploadAvatar(avatarFile, data.user.id);
43+
} catch (uploadError) {
44+
// Si falla la subida del avatar, continuar sin él
45+
console.error("Error al subir avatar:", uploadError);
46+
// No lanzamos error aquí para no bloquear el registro
47+
}
48+
}
49+
5150
const dbResponse = await supabase.from("student").insert([
5251
{
5352
name,
5453
surname,
55-
avatar: avatar_url || null,
56-
supabase_user_id: data.user?.id,
54+
avatar: avatarUrl,
55+
supabase_user_id: data.user.id,
5756
},
5857
]);
5958

src/app/profile/actions.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use server";
2+
3+
import { uploadAvatar, deleteAvatar } from "@/lib/supabase/storage";
4+
import { getUser } from "@/controllers/supabase.controller";
5+
6+
/**
7+
* Server action para subir un avatar desde el cliente
8+
*/
9+
export async function uploadAvatarAction(formData: FormData) {
10+
try {
11+
const file = formData.get("avatar") as File;
12+
const user = await getUser();
13+
14+
if (!user?.id) {
15+
return { error: "Usuario no autenticado", url: null };
16+
}
17+
18+
if (!file || file.size === 0) {
19+
return { error: "No se proporcionó ningún archivo", url: null };
20+
}
21+
22+
const avatarUrl = await uploadAvatar(file, user.id);
23+
return { error: null, url: avatarUrl };
24+
} catch (error) {
25+
return { error: (error as Error).message, url: null };
26+
}
27+
}
28+
29+
/**
30+
* Server action para eliminar un avatar
31+
*/
32+
export async function deleteAvatarAction(avatarUrl: string) {
33+
try {
34+
await deleteAvatar(avatarUrl);
35+
return { error: null };
36+
} catch (error) {
37+
return { error: (error as Error).message };
38+
}
39+
}

src/app/profile/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default function ProfilePage() {
2121
isEditing,
2222
formData,
2323
handleEditing,
24-
handleAvatarUrlChange,
24+
handleAvatarFileChange,
2525
handleSave,
2626
handleInputChange,
2727
setFormData,
@@ -49,7 +49,7 @@ export default function ProfilePage() {
4949
onEditToggle={handleEditing}
5050
onSave={handleSave}
5151
onInputChange={handleInputChange}
52-
onAvatarUrlChange={handleAvatarUrlChange}
52+
onAvatarFileChange={handleAvatarFileChange}
5353
setFormData={setFormData}
5454
/>
5555
)}

src/components/profile/profile-header.tsx

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Student } from "@/models/student.model";
22
import Link from "next/link";
3-
import React from "react";
3+
import React, { useRef } from "react";
4+
import { Upload, X } from "lucide-react";
45

56
interface ProfileHeaderProps {
67
student: Student | null;
@@ -12,18 +13,22 @@ interface ProfileHeaderProps {
1213
email: string;
1314
codeforcesHandle: string;
1415
avatarUrl: string | null;
16+
avatarFile: File | null;
17+
avatarPreview: string | null;
1518
};
1619
onEditToggle: () => void;
1720
onSave: () => void;
1821
onInputChange: (field: string, value: string) => void;
19-
onAvatarUrlChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
22+
onAvatarFileChange: (file: File | null, preview: string | null) => void;
2023
setFormData: React.Dispatch<
2124
React.SetStateAction<{
2225
name: string;
2326
surname: string;
2427
email: string;
2528
codeforcesHandle: string;
2629
avatarUrl: string | null;
30+
avatarFile: File | null;
31+
avatarPreview: string | null;
2732
}>
2833
>;
2934
}
@@ -36,9 +41,45 @@ export const ProfileHeader = ({
3641
onEditToggle,
3742
onSave,
3843
onInputChange,
39-
onAvatarUrlChange,
44+
onAvatarFileChange,
4045
setFormData,
4146
}: ProfileHeaderProps) => {
47+
const fileInputRef = useRef<HTMLInputElement>(null);
48+
49+
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
50+
const file = e.target.files?.[0];
51+
if (file) {
52+
// Validar tipo de archivo
53+
if (!file.type.startsWith("image/")) {
54+
alert("Por favor, selecciona un archivo de imagen válido.");
55+
return;
56+
}
57+
// Validar tamaño (1MB)
58+
if (file.size > 5 * 1024 * 1024) {
59+
alert("El archivo es demasiado grande. Máximo 1MB.");
60+
return;
61+
}
62+
// Crear preview
63+
const reader = new FileReader();
64+
reader.onloadend = () => {
65+
onAvatarFileChange(file, reader.result as string);
66+
};
67+
reader.readAsDataURL(file);
68+
}
69+
};
70+
71+
const handleRemoveAvatar = () => {
72+
onAvatarFileChange(null, null);
73+
if (fileInputRef.current) {
74+
fileInputRef.current.value = "";
75+
}
76+
};
77+
78+
// Determinar qué imagen mostrar
79+
const displayImage =
80+
formData.avatarPreview ||
81+
(isEditing ? formData.avatarUrl : student?.avatar) ||
82+
null;
4283
return (
4384
<section className="bg-(--white) dark:bg-gray-800 rounded-xl shadow-sm border border-(--azul-niebla) dark:border-gray-700">
4485
<div className="p-6 border-b border-(--azul-niebla) dark:border-gray-700 flex items-center justify-start">
@@ -72,17 +113,18 @@ export const ProfileHeader = ({
72113
{/* Avatar editable */}
73114
<div className="flex flex-col items-center md:w-1/3">
74115
<div className="relative w-28 h-28 md:w-32 md:h-32 rounded-full overflow-hidden ring-4 ring-(--azul-niebla) dark:ring-blue-900">
75-
{(isEditing ? formData.avatarUrl : student?.avatar) ? (
116+
{displayImage ? (
76117
<img
77-
src={
78-
(isEditing ? formData.avatarUrl : student?.avatar) ||
79-
undefined
80-
}
118+
src={displayImage}
81119
alt="Avatar"
82120
className="w-full h-full object-cover"
83121
onError={() => {
84122
if (isEditing) {
85-
setFormData((prev) => ({ ...prev, avatarUrl: null }));
123+
setFormData((prev) => ({
124+
...prev,
125+
avatarUrl: null,
126+
avatarPreview: null,
127+
}));
86128
}
87129
}}
88130
/>
@@ -97,17 +139,37 @@ export const ProfileHeader = ({
97139
{isEditing && (
98140
<div className="w-full mt-4 space-y-2">
99141
<input
100-
type="url"
101-
value={formData.avatarUrl || ""}
102-
onChange={onAvatarUrlChange}
103-
placeholder="www.example.com/avatar.png"
104-
className="w-full px-3 py-2 rounded-lg bg-(--azul-niebla) dark:bg-gray-700 text-(--azul-noche) dark:text-white border border-(--azul-niebla) dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-(--azul-electrico) text-sm"
142+
ref={fileInputRef}
143+
type="file"
144+
accept="image/jpeg,image/jpg,image/png,image/webp"
145+
onChange={handleAvatarChange}
146+
className="hidden"
147+
id="avatar-upload"
105148
/>
106-
{formData.avatarUrl && (
107-
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
108-
La imagen se previsualizará arriba
109-
</p>
149+
<label
150+
htmlFor="avatar-upload"
151+
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg bg-(--azul-niebla) dark:bg-gray-700 text-(--azul-noche) dark:text-white border border-(--azul-niebla) dark:border-gray-600 hover:bg-(--azul-crayon) dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
152+
>
153+
<Upload className="h-4 w-4" />
154+
<span>
155+
{formData.avatarFile
156+
? formData.avatarFile.name
157+
: "Seleccionar imagen"}
158+
</span>
159+
</label>
160+
{formData.avatarFile && (
161+
<button
162+
type="button"
163+
onClick={handleRemoveAvatar}
164+
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors text-sm font-medium"
165+
>
166+
<X className="h-4 w-4" />
167+
<span>Eliminar imagen</span>
168+
</button>
110169
)}
170+
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
171+
Formatos: JPEG, PNG, WEBP. Máximo 1MB
172+
</p>
111173
</div>
112174
)}
113175
</div>

0 commit comments

Comments
 (0)