Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion cs17_portal/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,30 @@ def get_user_profile() -> dict | None:
return frappe.db.get_value(
"CS17 Profile",
{"user": frappe.session.user},
["name", "full_name", "profile_type", "cohort", "profile_picture"],
["name", "full_name", "first_name", "last_name", "profile_type", "cohort", "profile_picture"],
as_dict=True,
)


@frappe.whitelist(methods=["POST"])
def update_my_profile(first_name: str, last_name: str, profile_picture: str = "") -> dict:
profile = validate_membership("Faculty")
doc = frappe.get_doc("CS17 Profile", profile)
doc.update(
{
"first_name": first_name,
"last_name": last_name,
"profile_picture": profile_picture or None,
}
)
doc.save(ignore_permissions=True)
return {
"name": doc.name,
"full_name": doc.full_name,
"profile_picture": doc.profile_picture,
}


@frappe.whitelist(methods=["GET"])
def get_faculty_assignments(cohort: str | None = None) -> list:
validate_membership("Faculty")
Expand Down
10 changes: 9 additions & 1 deletion cs17_portal/www/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@ def get_boot():
profiles = frappe.get_list(
"CS17 Profile",
filters={"user": current_user},
fields=["name", "full_name", "profile_type", "cohort", "profile_picture"],
fields=[
"name",
"full_name",
"first_name",
"last_name",
"profile_type",
"cohort",
"profile_picture",
],
limit=1,
ignore_permissions=True,
)
Expand Down
6 changes: 6 additions & 0 deletions dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/assets/cs17_portal/dashboard/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CS17 Portal</title>
<script>
try {
if (localStorage.getItem("theme") === "dark")
document.documentElement.classList.add("dark");
} catch (e) {}
</script>
<script type="module" src="/src/main.tsx"></script>
</head>
<body>
Expand Down
168 changes: 168 additions & 0 deletions dashboard/src/faculty/FacultyProfileCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useRef, useState } from "react";
import { Check, Loader2, Pencil, X } from "lucide-react";
import { useFrappeFileUpload, useFrappePostCall } from "frappe-react-sdk";
import {
updateCurrentProfile,
useCurrentProfile,
} from "@/hooks/useCurrentProfile";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

const UPDATE_PROFILE = "cs17_portal.api.update_my_profile";

export default function FacultyProfileCard() {
const { profile } = useCurrentProfile();
const fileInput = useRef<HTMLInputElement>(null);
const [editingName, setEditingName] = useState(false);
const [firstName, setFirstName] = useState(profile?.first_name ?? "");
const [lastName, setLastName] = useState(profile?.last_name ?? "");
const [error, setError] = useState<string | null>(null);
const { upload, loading: uploading } = useFrappeFileUpload();
const { call, loading: saving } = useFrappePostCall(UPDATE_PROFILE);

async function save(patch: { first_name?: string; last_name?: string; profile_picture?: string }) {
setError(null);
const payload = {
first_name: patch.first_name ?? profile?.first_name ?? "",
last_name: patch.last_name ?? profile?.last_name ?? "",
profile_picture: patch.profile_picture ?? profile?.profile_picture ?? "",
};
try {
const saved = await call(payload);
updateCurrentProfile({ ...payload, full_name: saved.message.full_name });
return true;
} catch (err: any) {
setError(err?.message ?? "Could not save your profile. Please try again.");
return false;
}
}

async function handlePhotoChange(file: File | null) {
if (!file) return;
setError(null);
if (!file.type.startsWith("image/")) {
setError("Choose an image file.");
return;
}
try {
const uploaded = await upload(file, { isPrivate: false });
await save({ profile_picture: uploaded.file_url });
} catch (err: any) {
setError(err?.message ?? "Could not upload the photo. Please try again.");
}
}

function startEditingName() {
setFirstName(profile?.first_name ?? "");
setLastName(profile?.last_name ?? "");
setEditingName(true);
}

async function saveName() {
if (!firstName.trim() || !lastName.trim()) {
setError("First and last name are both required.");
return;
}
const saved = await save({
first_name: firstName.trim(),
last_name: lastName.trim(),
});
if (saved) setEditingName(false);
}

const busy = uploading || saving;

return (
<div className="border border-border rounded-xl p-4 space-y-3">
<div className="flex items-center gap-4">
<div className="relative group shrink-0">
{profile?.profile_picture ? (
<img
src={profile.profile_picture}
alt="Profile photo"
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-lg font-semibold">
{profile?.full_name?.[0] ?? "?"}
</div>
)}
<button
type="button"
aria-label={profile?.profile_picture ? "Change photo" : "Upload photo"}
onClick={() => fileInput.current?.click()}
disabled={busy}
className="absolute inset-0 rounded-full cursor-pointer opacity-0 group-hover:opacity-100 bg-black/40 text-white flex items-center justify-center transition-opacity"
>
<Pencil className="size-4" />
</button>
{profile?.profile_picture && (
<button
type="button"
aria-label="Remove photo"
onClick={() => save({ profile_picture: "" })}
disabled={busy}
className="absolute -top-1 -right-1 size-4 rounded-full bg-background border border-border text-muted-foreground opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
>
<X className="size-3" />
</button>
)}
{uploading && (
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
<Loader2 className="size-4 text-white animate-spin" />
</div>
)}
<input
ref={fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => handlePhotoChange(event.target.files?.[0] ?? null)}
/>
</div>

{editingName ? (
<div className="flex items-center gap-2">
<Input
aria-label="First name"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
className="h-8 w-28"
/>
<Input
aria-label="Last name"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
className="h-8 w-28"
/>
<Button
variant="outline"
size="icon-sm"
aria-label="Save name"
onClick={saveName}
disabled={busy}
>
<Check className="size-3.5" />
</Button>
</div>
) : (
<div>
<div className="flex items-center gap-1">
<p className="text-sm font-medium">{profile?.full_name ?? "—"}</p>
<Button
variant="ghost"
size="icon-xs"
aria-label="Edit name"
onClick={startEditingName}
>
<Pencil className="size-3" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Faculty</p>
</div>
)}
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
45 changes: 4 additions & 41 deletions dashboard/src/faculty/FacultySettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import FacultyProfileCard from "@/faculty/FacultyProfileCard";
import {
Dialog,
DialogContent,
Expand All @@ -9,29 +9,10 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

function getInitialTheme(): "light" | "dark" {
try {
const stored = localStorage.getItem("theme");
if (stored === "dark" || stored === "light") return stored;
} catch {}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}

function applyTheme(theme: "light" | "dark") {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
try {
localStorage.setItem("theme", theme);
} catch {}
}
import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme";

export default function FacultySettingsPage() {
const { profile } = useCurrentProfile();
const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
const [theme, setTheme] = useState<Theme>(getStoredTheme);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);

function toggleTheme() {
Expand All @@ -56,25 +37,7 @@ export default function FacultySettingsPage() {
<h2 className="text-xs font-semibold text-muted-foreground tracking-widest uppercase mb-3">
Profile
</h2>
<div className="border border-border rounded-xl p-4 space-y-3">
<div className="flex items-center gap-4">
{profile?.profile_picture ? (
<img
src={profile.profile_picture}
alt={profile?.full_name}
className="w-12 h-12 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-lg font-semibold shrink-0">
{profile?.full_name?.[0] ?? "?"}
</div>
)}
<div>
<p className="text-sm font-medium">{profile?.full_name ?? "—"}</p>
<p className="text-xs text-muted-foreground">Faculty</p>
</div>
</div>
</div>
<FacultyProfileCard />
</div>

<div>
Expand Down
28 changes: 24 additions & 4 deletions dashboard/src/hooks/useCurrentProfile.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
import { useSyncExternalStore } from "react";

interface Profile {
name: string;
full_name: string;
first_name: string;
last_name: string;
profile_type: "Student" | "Faculty";
cohort: string | null;
profile_picture: string;
}

export function useCurrentProfile() {
const boot = (window as any).frappe_boot;
const profile: Profile | null = boot?.profile ?? null;
const getBoot = () => (window as any).frappe_boot;

let profile: Profile | null = getBoot()?.profile ?? null;
const listeners = new Set<() => void>();

function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function updateCurrentProfile(patch: Partial<Profile>) {
if (!profile) return;
profile = { ...profile, ...patch };
if (getBoot()) getBoot().profile = profile;
listeners.forEach((listener) => listener());
}

export function useCurrentProfile() {
return {
profile,
profile: useSyncExternalStore(subscribe, () => profile),
isLoading: false,
error: null,
};
Expand Down
20 changes: 20 additions & 0 deletions dashboard/src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export type Theme = "light" | "dark";

export function getStoredTheme(): Theme {
try {
const stored = localStorage.getItem("theme");
if (stored === "dark" || stored === "light") return stored;
} catch {}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}

export function applyTheme(theme: Theme): void {
setThemeClass(theme);
try {
localStorage.setItem("theme", theme);
} catch {}
}

function setThemeClass(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
}
22 changes: 2 additions & 20 deletions dashboard/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,11 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

function getInitialTheme(): "light" | "dark" {
try {
const stored = localStorage.getItem("theme");
if (stored === "dark" || stored === "light") return stored;
} catch {}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}

function applyTheme(theme: "light" | "dark") {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
try {
localStorage.setItem("theme", theme);
} catch {}
}
import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme";

export default function SettingsPage() {
const { student } = useCurrentStudent();
const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
const [theme, setTheme] = useState<Theme>(getStoredTheme);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);

function toggleTheme() {
Expand Down
Loading
Loading