diff --git a/app/page.tsx b/app/page.tsx index 626192d..5c285cb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,55 @@ +// app/page.tsx +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { createClient } from "@/lib/supabase/client"; +import Navbar from "@/components/Navbar"; +import ProfileCard from "@/components/ProfileCard"; +import { Search } from "lucide-react"; +import type { ProfileDTO } from "@/lib/dto/profile"; +import type { User } from "@supabase/supabase-js"; + +const supabase = createClient(); +const SESSION_KEY = "connectcs-profile-order"; + +function shuffle(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function applyStoredOrder(source: ProfileDTO[]): ProfileDTO[] { + const stored = sessionStorage.getItem(SESSION_KEY); + + if (stored) { + const order: string[] = JSON.parse(stored); + const known = [...source].sort( + (a, b) => order.indexOf(a.userId) - order.indexOf(b.userId) + ); + const newProfiles = source.filter((p) => !order.includes(p.userId)); + if (newProfiles.length > 0) { + const updated = [...order, ...newProfiles.map((p) => p.userId)]; + sessionStorage.setItem(SESSION_KEY, JSON.stringify(updated)); + } + return [...known, ...newProfiles]; + } + + const shuffled = shuffle(source); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify(shuffled.map((p) => p.userId)) + ); + return shuffled; +} + +export default function HomePage() { + const [user, setUser] = useState(null); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(""); "use client"; import Link from "next/link"; @@ -34,146 +86,72 @@ export default function Home() { }); return () => subscription.unsubscribe(); - }, [supabase]); + }, []); useEffect(() => { - let active = true; - - async function loadProfiles() { - setLoadingProfiles(true); - setErrorMessage(null); - - try { - const response = await fetch("/api/v1/profiles"); - if (!active) return; - - if (!response.ok) { - setErrorMessage("Failed to load profiles."); - return; - } - - const data = (await response.json()) as ProfileDTO[]; - const normalized = data.filter( - (profile): profile is ProfileWithId => Boolean(profile.id), - ); - setProfiles(normalized); - } catch { - if (active) { - setErrorMessage("Failed to load profiles."); - } - } finally { - if (active) { - setLoadingProfiles(false); - } - } - } + async function fetchProfiles() { + setLoading(true); - loadProfiles(); - - return () => { - active = false; - }; - }, []); - - const filteredProfiles = useMemo(() => { - const term = search.trim().toLowerCase(); - if (!term) { - return profiles; + const { data, error } = await supabase.from("profiles").select("*"); + const source: ProfileDTO[] = error ? [] : (data as ProfileDTO[]); + setProfiles(applyStoredOrder(source)); + setLoading(false); } - return profiles.filter((profile) => { - const experienceText = (profile.experiences ?? []) - .map((exp) => [exp.company, exp.role].filter(Boolean).join(" ")) - .filter(Boolean) - .join(" "); - const haystack = [ - profile.name, - profile.start_term, - profile.grad_year, - experienceText, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return haystack.includes(term); - }); - }, [profiles, search]); + fetchProfiles(); + }, []); - const profileCountLabel = loadingProfiles - ? "Loading profiles..." - : `${filteredProfiles.length} profile${filteredProfiles.length === 1 ? "" : "s"}`; + const filtered = useMemo(() => { + if (!query.trim()) return profiles; + return profiles.filter((p) => + p.name?.toLowerCase().includes(query.toLowerCase()) + ); + }, [profiles, query]); return (
-
-
-
-

- ConnectCS directory -

-

- Student Profiles -

-

- Browse classmates, internships, and project experience. -

-
- - {user && ( - +
+
+ + setQuery(e.target.value)} + /> + {query && ( + )}
-
-
- - setSearch(event.target.value)} - placeholder="Search by name, company, or role" - /> + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))}
-

- {profileCountLabel} + ) : filtered.length === 0 ? ( +

+ {query ? "No profiles match." : "No profiles yet."}

-
- - {errorMessage && ( -

- {errorMessage} -

- )} - - {loadingProfiles ? ( -
-

- Loading profiles... -

-
- ) : filteredProfiles.length === 0 ? ( -
-

- No profiles match your search -

-
) : ( -
- {filteredProfiles.map((profile) => ( - +
+ {filtered.map((profile) => ( + ))}
)} -
+
); -} +} \ No newline at end of file diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 2164e1c..c0b65b2 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,210 +1,126 @@ // app/profile/page.tsx "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useState, useEffect } from "react"; +import { createClient } from "@/lib/supabase/client"; import Navbar from "@/components/Navbar"; import ProfileCard from "@/components/ProfileCard"; import EditProfileModal from "@/components/EditProfileModal"; -import { createClient } from "@/lib/supabase/client"; -import { ProfileDTO } from "@/lib/dto/profile"; -import { User } from "@supabase/supabase-js"; - -const EMPTY_PROFILE: ProfileDTO = { - name: "", - start_term: "", - grad_year: "", - linkedin: "", - github: "", - experiences: [], -}; +import type { ProfileDTO } from "@/lib/dto/profile"; +import type { User } from "@supabase/supabase-js"; + +const supabase = createClient(); + +function emptyProfile(userId: string): ProfileDTO { + return { + userId, + name: "", + startTerm: ["Fall", new Date().getFullYear()], + endTerm: null, + contact: [], + experience: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +} export default function ProfilePage() { - const supabase = useMemo(() => createClient(), []); const [user, setUser] = useState(null); - const [profile, setProfile] = useState(EMPTY_PROFILE); + const [profile, setProfile] = useState(null); const [modalOpen, setModalOpen] = useState(false); - const [loadingProfile, setLoadingProfile] = useState(true); - const [savingProfile, setSavingProfile] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { supabase.auth.getSession().then(({ data }) => { setUser(data.session?.user ?? null); }); - const { data: { subscription } } = supabase.auth.onAuthStateChange((_e, session) => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_e, session) => { setUser(session?.user ?? null); }); return () => subscription.unsubscribe(); - }, [supabase]); + }, []); useEffect(() => { - let active = true; - - async function loadProfile() { - if (!user) { - setProfile(EMPTY_PROFILE); - setLoadingProfile(false); - return; - } - - setLoadingProfile(true); - setErrorMessage(null); - - try { - const response = await fetch(`/api/v1/profiles/${user.id}`); - if (!active) return; - - if (response.status === 404) { - setProfile(EMPTY_PROFILE); - return; - } - - if (!response.ok) { - setErrorMessage("Failed to load profile."); - return; - } - - const data = (await response.json()) as ProfileDTO; - setProfile({ - ...EMPTY_PROFILE, - ...data, - id: data.id ?? user.id, - experiences: data.experiences ?? [], - }); - } catch { - if (active) { - setErrorMessage("Failed to load profile."); - } - } finally { - if (active) { - setLoadingProfile(false); - } - } + if (!user) { + setLoading(false); + return; } - loadProfile(); + async function fetchProfile() { + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("userId", user!.id) + .single(); - return () => { - active = false; - }; + if (!error && data) setProfile(data as ProfileDTO); + setLoading(false); + } + + fetchProfile(); }, [user]); async function handleSave(data: ProfileDTO) { - if (!user) return false; - - const name = data.name.trim(); - const startTerm = data.start_term.trim(); - - if (!name || !startTerm) { - setErrorMessage("Name and start term are required."); - return false; - } + const { error } = await supabase.from("profiles").upsert(data); + if (!error) setProfile(data); + } - setSavingProfile(true); - setErrorMessage(null); - - const payload: ProfileDTO = { - ...data, - name, - start_term: startTerm, - grad_year: data.grad_year?.trim() || undefined, - linkedin: data.linkedin?.trim() || undefined, - github: data.github?.trim() || undefined, - experiences: data.experiences ?? [], - }; - - const method = profile.id ? "PUT" : "POST"; - const url = profile.id ? `/api/v1/profiles/${user.id}` : "/api/v1/profiles"; - - try { - const response = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const { error } = (await response.json()) as { error?: string }; - setErrorMessage(error ?? "Failed to save profile."); - return false; - } - - const saved = (await response.json()) as ProfileDTO; - setProfile({ - ...EMPTY_PROFILE, - ...saved, - id: saved.id ?? user.id, - experiences: saved.experiences ?? [], - }); - return true; - } catch { - setErrorMessage("Failed to save profile."); - return false; - } finally { - setSavingProfile(false); - } + if (loading) { + return ( +
+ +
+
+
+
+ ); } - const hasProfile = Boolean(profile.name); + if (!user) { + return ( +
+ +
+

+ Sign in to view your profile +

+
+
+ ); + } return (
-
- {!user ? ( -
-

- Sign in to create your profile -

-
- ) : loadingProfile ? ( -
-

- Loading profile... -

-
- ) : ( -
- {errorMessage && ( -

- {errorMessage} -

- )} -
-

Your Card

- {!hasProfile && ( - - )} -
- -
- setModalOpen(true)} - /> -
- - {savingProfile && ( -

- Saving profile... -

- )} -
- )} +
+
+

Your Card

+ + setModalOpen(true)} + /> + + {!profile && ( + + )} +
{modalOpen && ( setModalOpen(false)} /> diff --git a/components/EditProfileModal.tsx b/components/EditProfileModal.tsx index 2efd850..bdb18c8 100644 --- a/components/EditProfileModal.tsx +++ b/components/EditProfileModal.tsx @@ -1,239 +1,379 @@ +// components/EditProfileModal.tsx "use client"; -import { useEffect, useState } from "react"; +import { useState, useEffect } from "react"; import { X } from "lucide-react"; -import { ProfileDTO } from "@/lib/dto/profile"; - +import { createClient } from "@/lib/supabase/client"; +import type { + ProfileDTO, + Term, + TermSeason, + Experience, + Contact, +} from "@/lib/dto/profile"; interface Props { - initial: ProfileDTO; - onSave: (data: ProfileDTO) => Promise | boolean; - onClose: () => void; + initial: ProfileDTO; + onSave: (data: ProfileDTO) => void; + onClose: () => void; +} + +const SEASONS: TermSeason[] = ["Fall", "Winter", "Summer"]; + +// Flat form shape — tuples are hard to bind to inputs directly +interface FormState { + name: string; + startSeason: TermSeason; + startYear: string; + endSeason: TermSeason; + endYear: string; + isPresent: boolean; + linkedin: string; + github: string; + experiences: { company: string; role: string }[]; +} + +function profileToForm(p: ProfileDTO): FormState { + const linkedin = p.contact.find(([t]) => t === "LinkedIn")?.[1] ?? ""; + const github = p.contact.find(([t]) => t === "GitHub")?.[1] ?? ""; + return { + name: p.name ?? "", + startSeason: p.startTerm[0], + startYear: String(p.startTerm[1]), + endSeason: p.endTerm?.[0] ?? "Fall", + endYear: p.endTerm ? String(p.endTerm[1]) : "", + isPresent: !p.endTerm, + linkedin, + github, + experiences: p.experience.map((exp) => ({ + company: exp[2], + role: exp[1], + })), + }; } -const COMPANIES = ["Ubisoft", "Bold Commerce", "SkipTheDishes", "AAFC", "Niche"]; +function formToProfile(form: FormState, original: ProfileDTO): ProfileDTO { + const contact: Contact[] = []; + if (form.linkedin) contact.push(["LinkedIn", form.linkedin]); + if (form.github) contact.push(["GitHub", form.github]); + + const experience: Experience[] = form.experiences.map(({ company, role }) => [ + "Other", + role, + company, + new Date(), + null, + ]); + + const startTerm: Term = [form.startSeason, Number(form.startYear)]; + const endTerm: Term | null = form.isPresent + ? null + : [form.endSeason, Number(form.endYear)]; + + return { + ...original, + name: form.name, + startTerm, + endTerm, + contact, + experience, + updatedAt: new Date(), + }; +} export default function EditProfileModal({ initial, onSave, onClose }: Props) { - const emptyProfile: ProfileDTO = { - id: initial?.id, - name: "", - start_term: "", - grad_year: "", - linkedin: "", - github: "", - experiences: [], - }; - - const [form, setForm] = useState(initial || emptyProfile); - const [companies, setCompanies] = useState(COMPANIES); - - const [expInput, setExpInput] = useState({ company: "", role: "" }); - const [companyQuery, setCompanyQuery] = useState(""); - const [showSuggestions, setShowSuggestions] = useState(false); - const [saveError, setSaveError] = useState(null); - - useEffect(() => { - let active = true; - - async function loadCompanies() { - try { - const response = await fetch("/api/v1/companies"); - if (!response.ok) return; - const data = await response.json(); - if (active && Array.isArray(data)) { - setCompanies(data); - } - } catch { - // Fallback to static list - } - } - - loadCompanies(); - - return () => { - active = false; - }; - }, []); - - type EditableField = "name" | "start_term" | "grad_year" | "linkedin" | "github"; - - function handleField(field: EditableField, value: string) { - setForm((prev) => ({ ...prev, [field]: value })); - } + const [form, setForm] = useState(() => profileToForm(initial)); + const [companies, setCompanies] = useState([]); + const [companyQuery, setCompanyQuery] = useState(""); + const [expRole, setExpRole] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const supabase = createClient(); - const suggestions = - companyQuery.length > 0 - ? companies.filter((c) => - c.toLowerCase().includes(companyQuery.toLowerCase()), - ) - : []; - const canCreate = - companyQuery.length > 0 && - !companies.some((c) => c.toLowerCase() === companyQuery.toLowerCase()); - - function selectCompany(name: string) { - setExpInput((prev) => ({ ...prev, company: name })); - setCompanyQuery(name); - setShowSuggestions(false); + useEffect(() => { + async function fetchCompanies() { + const { data } = await supabase.from("companies").select("name"); + setCompanies(data?.map((c: { name: string }) => c.name) ?? []); } + fetchCompanies(); + }, []); - function addExperience() { - if (!expInput.company) return; - setForm((prev) => ({ - ...prev, - experiences: [...prev.experiences ?? [], expInput], - })); - setExpInput({ company: "", role: "" }); - setCompanyQuery(""); - } + function setField(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } - function removeExperience(i: number) { - setForm((prev) => ({ - ...prev, - experiences: (prev.experiences ?? []).filter((_, idx) => idx !== i), - })); - } + const suggestions = + companyQuery.length > 0 + ? companies.filter((c) => + c.toLowerCase().includes(companyQuery.toLowerCase()) + ) + : []; - async function handleSave() { - setSaveError(null); + const canCreate = + companyQuery.length > 0 && + !companies.some((c) => c.toLowerCase() === companyQuery.toLowerCase()); - try { - const saved = await onSave(form); - if (saved) { - onClose(); - return; - } - } catch { - // Fall through to error state - } + function selectCompany(name: string) { + setCompanyQuery(name); + setShowSuggestions(false); + } - setSaveError("Failed to save profile."); + async function addExperience() { + if (!companyQuery.trim()) return; + + if (canCreate) { + await supabase + .from("companies") + .upsert({ name: companyQuery }, { onConflict: "name" }); + setCompanies((prev) => [...prev, companyQuery]); } - return ( -
-
e.stopPropagation()} - > -
-

Edit Profile

- -
+ setField("experiences", [ + ...form.experiences, + { company: companyQuery, role: expRole }, + ]); + setCompanyQuery(""); + setExpRole(""); + } + + function removeExperience(i: number) { + setField( + "experiences", + form.experiences.filter((_, idx) => idx !== i) + ); + } + + function handleSave() { + const dto = formToProfile(form, initial); + onSave(dto); + onClose(); + } + + return ( +
+
e.stopPropagation()} + > +
+

+ Edit Profile +

+ +
-
-
-

Info

- handleField("name", v)} /> - handleField("start_term", v)} /> - handleField("grad_year", v)} /> - handleField("linkedin", v)} /> - handleField("github", v)} /> -
- -
-

Experience

- {(form.experiences ?? []).map((exp, i) => ( -
-
-

{exp.company}

-

{exp.role}

-
- -
- ))} - -
-
- { - setCompanyQuery(e.target.value); - setExpInput((p) => ({ ...p, company: e.target.value })); - setShowSuggestions(true); - }} - onFocus={() => setShowSuggestions(true)} - /> - - {showSuggestions && (suggestions.length > 0 || canCreate) && ( -
- {suggestions.map((c) => ( - - ))} - {canCreate && ( - - )} -
- )} -
- - setExpInput((p) => ({ ...p, role: e.target.value }))} - /> - - -
-
- - {saveError && ( -

- {saveError} -

+
+
+

+ Info +

+ setField("name", v)} + /> + setField("linkedin", v)} + /> + setField("github", v)} + /> +
+ +
+

+ Term +

+ +
+ +
+ + setField("startYear", e.target.value)} + /> +
+
+ +
+
+ + +
+ + {!form.isPresent && ( +
+ + setField("endYear", e.target.value)} + /> +
+ )} +
+
- {/* Save */} - +
+

+ Experience +

+ + {form.experiences.map((exp, i) => ( +
+
+

{exp.company}

+

{exp.role}

+ +
+ ))} + +
+
+ { + setCompanyQuery(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + /> + {showSuggestions && (suggestions.length > 0 || canCreate) && ( +
+ {suggestions.map((c) => ( + + ))} + {canCreate && ( + + )} +
+ )} +
+ + setExpRole(e.target.value)} + /> + +
+
+ +
- ); +
+
+ ); } -function Field({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { - return ( -
- - onChange(e.target.value)} - /> -
- ); +function Field({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ + onChange(e.target.value)} + /> +
+ ); } \ No newline at end of file diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 76264dc..0b73f48 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,37 +1,76 @@ +// components/Navbar.tsx "use client"; import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { createClient } from "@/lib/supabase/client"; import type { User } from "@supabase/supabase-js"; -import { Button } from "@/components/ui/button"; -import { LogoutButton } from "@/components/logout-button"; -interface NavbarProps { - user: User | null; -} +export default function Navbar({ user }: { user: User | null }) { + const pathname = usePathname(); + const supabase = createClient(); + + async function handleSignOut() { + await supabase.auth.signOut(); + window.location.href = "/"; + } -export default function Navbar({ user }: NavbarProps) { return ( -