Skip to content
Merged
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
104 changes: 96 additions & 8 deletions app/frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
"use client";

import { useState } from "react";
import { useState, useEffect } from "react";
import Image from "next/image";
import Link from "next/link";
import { NetworkBadge } from "@/components/NetworkBadge";
import { LocaleSwitcher } from "@/components/LocaleSwitcher";
import '@/lib/i18n';
import { useTranslation } from "react-i18next";
import { useApi } from "@/hooks/useApi";
import { getProfile, saveProfile } from "@/lib/api";
import { validateProfile, type Profile, type ProfileValidationErrors } from "@/types/profile";

export default function Settings() {
const { t } = useTranslation();
const [form, setForm] = useState({
const [form, setForm] = useState<Profile>({
username: "john_doe",
primaryColor: "#6366f1",
avatarUrl: "",
Expand All @@ -20,10 +23,49 @@ export default function Settings() {
githubHandle: "",
});

const [errors, setErrors] = useState<ProfileValidationErrors>({});
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);

const handleSave = () => {
// TODO: Call API to save profile
const { error: apiError, loading, callApi } = useApi<Profile>();

useEffect(() => {
let active = true;
const loadData = async () => {
try {
const data = await getProfile("john_doe");
if (active && data) {
setForm(data);
}
} catch (err) {
console.error("Failed to load profile settings", err);
}
};
loadData();
return () => {
active = false;
};
}, []);

const handleSave = async () => {
setSuccessMessage(null);
setErrors({});

const validation = validateProfile(form);
if (!validation.isValid) {
setErrors(validation.errors);
return;
}

try {
await callApi(() => saveProfile(form));
setSuccessMessage("Settings saved successfully!");
setTimeout(() => {
setSuccessMessage(null);
}, 5000);
} catch (err) {
console.error("Save profile error", err);
}
};

return (
Expand Down Expand Up @@ -118,6 +160,18 @@ export default function Settings() {
</Link>
</nav>

{successMessage && (
<div className="mb-6 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm font-semibold flex items-center gap-2 animate-in fade-in slide-in-from-top-4 duration-200">
<span>✓</span> {successMessage}
</div>
)}

{apiError && (
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-semibold flex items-center gap-2 animate-in fade-in slide-in-from-top-4 duration-200">
<span>⚠️</span> {apiError}
</div>
)}

<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 lg:gap-8">
{/* Settings Form */}
<div className="space-y-4 sm:space-y-6">
Expand Down Expand Up @@ -151,6 +205,9 @@ export default function Settings() {
placeholder="#6366f1"
/>
</div>
{errors.primaryColor && (
<p className="text-red-500 text-xs mt-1.5 font-medium">{errors.primaryColor}</p>
)}
</div>

<div>
Expand All @@ -166,6 +223,9 @@ export default function Settings() {
className="w-full px-3 sm:px-4 py-2.5 sm:py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm sm:text-base"
placeholder="https://example.com/avatar.jpg"
/>
{errors.avatarUrl && (
<p className="text-red-500 text-xs mt-1.5 font-medium">{errors.avatarUrl}</p>
)}
</div>

<div>
Expand All @@ -183,6 +243,9 @@ export default function Settings() {
<p className="text-xs text-neutral-600 mt-1">
{form.bio.length}/160 characters
</p>
{errors.bio && (
<p className="text-red-500 text-xs mt-1.5 font-medium">{errors.bio}</p>
)}
</div>
</div>
</div>
Expand Down Expand Up @@ -223,6 +286,9 @@ export default function Settings() {
maxLength={15}
/>
</div>
{errors.twitterHandle && (
<p className="text-red-500 text-xs mt-1.5 font-medium">{errors.twitterHandle}</p>
)}
</div>

<div>
Expand All @@ -239,6 +305,9 @@ export default function Settings() {
placeholder="user#1234"
maxLength={32}
/>
{errors.discordHandle && (
<p className="text-red-500 text-xs mt-1.5 font-medium">{errors.discordHandle}</p>
)}
</div>

<div>
Expand All @@ -255,6 +324,9 @@ export default function Settings() {
placeholder="stellar"
maxLength={39}
/>
{errors.githubHandle && (
<p className="text-red-500 text-xs mt-1.5 font-medium">{errors.githubHandle}</p>
)}
</div>
</div>
</div>
Expand All @@ -263,9 +335,17 @@ export default function Settings() {
<div className="hidden sm:flex gap-3 sm:gap-4">
<button
onClick={handleSave}
className="flex-1 px-4 sm:px-6 py-3 sm:py-4 bg-indigo-500 text-white font-bold rounded-xl hover:scale-105 active:scale-95 transition text-sm sm:text-base"
disabled={loading}
className="flex-1 px-4 sm:px-6 py-3 sm:py-4 bg-indigo-500 text-white font-bold rounded-xl hover:scale-105 active:scale-95 transition text-sm sm:text-base disabled:opacity-50 disabled:scale-100 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{t('saveChanges')}
{loading ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Saving...
</>
) : (
t('saveChanges')
)}
</button>
<button
onClick={() => setShowPreview(!showPreview)}
Expand Down Expand Up @@ -307,9 +387,17 @@ export default function Settings() {
<div className="flex gap-3">
<button
onClick={handleSave}
className="flex-1 px-4 py-3 bg-indigo-500 text-white font-bold rounded-xl active:scale-95 transition"
disabled={loading}
className="flex-1 px-4 py-3 bg-indigo-500 text-white font-bold rounded-xl active:scale-95 transition disabled:opacity-50 disabled:scale-100 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{t('saveChanges')}
{loading ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Saving...
</>
) : (
t('saveChanges')
)}
</button>
<button
onClick={() => setShowPreview(!showPreview)}
Expand Down
2 changes: 2 additions & 0 deletions app/frontend/src/app/settings/teams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react";
import Link from "next/link";
import { NetworkBadge } from "@/components/NetworkBadge";

interface TeamMember {
id: string;
Expand Down Expand Up @@ -56,6 +57,7 @@ export default function TeamSettings() {

return (
<div className="relative min-h-screen text-white">
<NetworkBadge />
{/* Background glows */}
<div className="fixed top-[-20%] left-[-30%] w-[60%] h-[60%] bg-indigo-500/10 blur-[120px] rounded-full" />

Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/components/NetworkBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function NetworkBadge() {

return (
<div
className={`fixed top-4 left-60 px-3 py-1 rounded-full text-xs font-bold transition-all ${badgeStyles[normalized] || ""}`}
className={`fixed top-4 right-4 md:right-auto md:left-80 px-3 py-1 rounded-full text-xs font-bold transition-all z-50 ${badgeStyles[normalized] || ""}`}
>
{label}

Expand Down
2 changes: 2 additions & 0 deletions app/frontend/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function useApi<T>() {
try {
const res = await fn();
setData(res);
setError(null);
return res;
} catch (err: unknown) {
const msg =
Expand All @@ -22,6 +23,7 @@ export function useApi<T>() {
: "Something went wrong. Please try again.";
setError(msg);
console.error(err);
throw err;
} finally {
setLoading(false);
}
Expand Down
45 changes: 45 additions & 0 deletions app/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,52 @@
import { Profile } from "@/types/profile";

/**
* Backend origin for browser calls. Override in `.env.local`:
* `NEXT_PUBLIC_RustAcademy_API_URL=https://api.example.com`
*/
export const getRustAcademyApiBase = (): string =>
process.env.NEXT_PUBLIC_RustAcademy_API_URL?.replace(/\/$/, "") ||
"http://localhost:4000";

/**
* Simulate API call to fetch a user profile, with localStorage fallback.
*/
export async function getProfile(username: string): Promise<Profile> {
// Simulate network latency
await new Promise((resolve) => setTimeout(resolve, 500));

if (typeof window !== "undefined") {
const stored = localStorage.getItem(`profile_${username}`);
if (stored) {
try {
return JSON.parse(stored) as Profile;
} catch (e) {
console.error("Failed to parse stored profile:", e);
}
}
}

// Return default profile
return {
username,
primaryColor: "#6366f1",
avatarUrl: "",
bio: "",
twitterHandle: "",
discordHandle: "",
githubHandle: "",
};
}

/**
* Simulate API call to save a user profile, persisting to localStorage.
*/
export async function saveProfile(profile: Profile): Promise<Profile> {
// Simulate network latency
await new Promise((resolve) => setTimeout(resolve, 800));

if (typeof window !== "undefined") {
localStorage.setItem(`profile_${profile.username}`, JSON.stringify(profile));
}
return profile;
}
80 changes: 80 additions & 0 deletions app/frontend/src/types/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export interface Profile {
username: string;
primaryColor: string;
avatarUrl: string;
bio: string;
twitterHandle: string;
discordHandle: string;
githubHandle: string;
}

export type ProfileValidationErrors = Partial<Record<keyof Profile, string>>;

export function validateProfile(profile: Profile): { isValid: boolean; errors: ProfileValidationErrors } {
const errors: ProfileValidationErrors = {};

// Username validation
if (!profile.username) {
errors.username = "Username is required";
} else if (profile.username.length < 3 || profile.username.length > 32) {
errors.username = "Username must be between 3 and 32 characters";
} else if (!/^[a-z0-9_]+$/.test(profile.username)) {
errors.username = "Username must contain only lowercase letters, numbers, and underscores";
}

// Primary Color validation
if (!profile.primaryColor) {
errors.primaryColor = "Primary color is required";
} else if (!/^#[0-9a-fA-F]{6}$/.test(profile.primaryColor)) {
errors.primaryColor = "Must be a valid hex color code (e.g. #6366f1)";
}

// Avatar URL validation
if (profile.avatarUrl) {
try {
const url = new URL(profile.avatarUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
errors.avatarUrl = "URL must use http or https protocol";
}
} catch {
errors.avatarUrl = "Must be a valid URL";
}
}

// Bio validation
if (profile.bio && profile.bio.length > 160) {
errors.bio = "Bio cannot exceed 160 characters";
}

// Twitter handle validation
if (profile.twitterHandle) {
if (profile.twitterHandle.length > 15) {
errors.twitterHandle = "Twitter handle cannot exceed 15 characters";
} else if (!/^[a-zA-Z0-9_]+$/.test(profile.twitterHandle)) {
errors.twitterHandle = "Twitter handle must contain only alphanumeric characters and underscores";
}
}

// Discord handle validation
if (profile.discordHandle) {
if (profile.discordHandle.length < 2 || profile.discordHandle.length > 32) {
errors.discordHandle = "Discord handle must be between 2 and 32 characters";
} else if (!/^[a-zA-Z0-9_.#]+$/.test(profile.discordHandle)) {
errors.discordHandle = "Discord handle must contain only alphanumeric characters, underscores, periods, and #";
}
}

// GitHub handle validation
if (profile.githubHandle) {
if (profile.githubHandle.length > 39) {
errors.githubHandle = "GitHub handle cannot exceed 39 characters";
} else if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/.test(profile.githubHandle)) {
errors.githubHandle = "Invalid GitHub handle format";
}
}

return {
isValid: Object.keys(errors).length === 0,
errors,
};
}