Skip to content

Commit c1edb91

Browse files
committed
Merge branch 'develop' into Feat/contestScreen
2 parents 19571bc + 75cd9d6 commit c1edb91

File tree

12 files changed

+385
-76
lines changed

12 files changed

+385
-76
lines changed

.github/workflows/dependabot.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "bun"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
- package-ecosystem: "github-actions"
8+
directory: "/"
9+
schedule:
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
@@ -15,7 +15,7 @@ export default function ProfilePage() {
1515
isEditing,
1616
formData,
1717
handleEditing,
18-
handleAvatarUrlChange,
18+
handleAvatarFileChange,
1919
handleSave,
2020
handleInputChange,
2121
setFormData,
@@ -43,7 +43,7 @@ export default function ProfilePage() {
4343
onEditToggle={handleEditing}
4444
onSave={handleSave}
4545
onInputChange={handleInputChange}
46-
onAvatarUrlChange={handleAvatarUrlChange}
46+
onAvatarFileChange={handleAvatarFileChange}
4747
setFormData={setFormData}
4848
/>
4949
)}

src/components/league/contest/contestant-details.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ export const ContestantDetails: React.FC<ContestantDetailsProps> = ({
2020
}) => {
2121
const t = useTranslations();
2222
const readyText = ready ? t("Match.ready") : t("Match.not_ready");
23-
const win_ratio: string = ((wins / (matches === 0 ? 1 : matches)) * 100).toFixed(2);
23+
const win_ratio: string = (
24+
(wins / (matches === 0 ? 1 : matches)) *
25+
100
26+
).toFixed(2);
2427

2528
const AvatarImage: ReactNode = (
2629
<div className="w-14 h-14 rounded-full bg-linear-to-br from-white/30 to-transparent border border-white/40 shadow-inner">
@@ -36,8 +39,9 @@ export const ContestantDetails: React.FC<ContestantDetailsProps> = ({
3639
<div
3740
className={`flex items-center ${!local_side ? "justify-end" : ""} gap-4 p-5 border border-white/20 rounded-2xl shadow-lg`}
3841
style={{
39-
background: `radial-gradient(circle at ${local_side ? "170% 35%" : "-10% 35%"
40-
}, ${local_side ? "var(--color-green-500)" : "var(--color-red-400)"}, transparent)`,
42+
background: `radial-gradient(circle at ${
43+
local_side ? "170% 35%" : "-10% 35%"
44+
}, ${local_side ? "var(--color-green-500)" : "var(--color-red-400)"}, transparent)`,
4145
}}
4246
>
4347
{local_side && AvatarImage}

src/components/league/tree-node.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default function TreeNode({
2020
}: TreeNodeProps) {
2121
const fillColor = "#ffffff";
2222
const isRoot = nodeDatum.__rd3t.depth === 0;
23+
const isLeaf = !nodeDatum.children || nodeDatum.children.length === 0;
2324
return (
2425
<g onClick={toggleNode}>
2526
<circle
@@ -38,7 +39,8 @@ export default function TreeNode({
3839
className="flex justify-center items-center rounded-full h-25 w-25"
3940
onClick={() =>
4041
nodeDatum.student != null &&
41-
nodeDatum.student.codeforces_handle != null
42+
nodeDatum.student.codeforces_handle != null &&
43+
isLeaf
4244
? window.open(
4345
"https://codeforces.com/profile/" +
4446
nodeDatum.student.codeforces_handle,

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>

src/components/shared/ui/avatar-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export default function AvatarMenu({ avatarUrl, userName }: AvatarMenuProps) {
5050
console.error("Error al cerrar sesión:", error);
5151
}
5252

53-
await queryClient.invalidateQueries({ queryKey: ["navbar-user"] });
53+
queryClient.clear();
5454

5555
router.push("/log-in");
5656
router.refresh();

src/components/sign-in/sign-in-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function SignInForm() {
2727
return;
2828
}
2929

30-
queryClient.invalidateQueries({ queryKey: ["navbar-user"] });
30+
queryClient.clear();
3131

3232
redirect("/");
3333
};

0 commit comments

Comments
 (0)