feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing

- Implement Social Features: Shared Tasks, Global Access, Privacy Settings (Leaderboard/Searchable).
- Add Leaderboard Page and API.
- Enhance Auth: Support Email/Username login, explicit duplicate registration errors.
- Fix: Admin login password hash regression.
- Refactor: Move to wouter for routing, add Admin Dashboard and User Management.
- Add Setup Wizard.
- Update UI with Sidebar and Gamification elements.
This commit is contained in:
2025-12-10 14:04:26 +01:00
parent d5b045158a
commit ccfb674318
52 changed files with 9206 additions and 1505 deletions
+469
View File
@@ -0,0 +1,469 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame } from 'lucide-react';
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Goal, Reward, User } from '@shared/schema';
import { getLevelFromXP, getRankKey } from '@/lib/gamification';
import { RewardCard } from '@/components/gamification/RewardCard';
import { useToast } from "@/hooks/use-toast";
export default function AchievementsPage({ user }: { user: User }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const [isGoalModalOpen, setIsGoalModalOpen] = useState(false);
// Mock User Data (Replace with context later)
// const user = { xp: 350, streak: 5, bestStreak: 12 };
const level = getLevelFromXP(user.xp);
const rankKey = getRankKey(level);
// Fetch Analytics Data
const { data: weeklyData } = useQuery({
queryKey: ['/api/analytics/weekly'],
queryFn: async () => {
const res = await fetch('/api/analytics/weekly');
return res.json();
}
});
const { data: yearlyData } = useQuery({
queryKey: ['/api/analytics/yearly'],
queryFn: async () => {
const res = await fetch('/api/analytics/yearly');
return res.json();
}
});
const { data: monthlyData } = useQuery({
queryKey: ['/api/analytics/monthly'],
queryFn: async () => {
const res = await fetch('/api/analytics/monthly');
return res.json();
}
});
// Fetch Goals
const { data: goals = [] } = useQuery<Goal[]>({
queryKey: ['/api/goals'],
});
// Create Goal Mutation
const createGoalMutation = useMutation({
mutationFn: async (newGoal: any) => {
const res = await fetch('/api/goals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newGoal)
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/goals'] });
setIsGoalModalOpen(false);
}
});
// Rewards
const { data: rewards = [] } = useQuery<Reward[]>({
queryKey: ['/api/rewards', user.id],
queryFn: async () => {
const res = await fetch(`/api/rewards?userId=${user.id}`);
return res.json();
}
});
const buyRewardMutation = useMutation({
mutationFn: async (rewardId: string) => {
const res = await fetch('/api/rewards/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rewardId, userId: user.id })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to buy");
}
return res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
// Invalidate user query if we had one for XP, assuming manual update for now or refetch
// user.xp -= data.cost... (but user object is static const in this file currently)
toast({
title: t('rewards.processing'), // Should contain success message really
description: "Purchase successful!",
});
},
onError: (error: Error) => {
toast({
title: t('common.error'),
description: t('rewards.insufficientFunds') === error.message ? t('rewards.insufficientFunds') : error.message,
variant: "destructive"
});
}
});
const handleBuyReward = (rewardId: string) => {
buyRewardMutation.mutate(rewardId);
};
const handleCreateGoal = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
createGoalMutation.mutate({
title: formData.get('title'),
target: parseInt(formData.get('target') as string),
type: formData.get('type'),
userId: user.id
});
};
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background/95 p-3 shadow-xl backdrop-blur-sm">
<div className="mb-1 text-xs font-medium text-muted-foreground">
{t(`analytics.${label}`) === `analytics.${label}` ?
(label && !isNaN(label) ? `${t('analytics.cw')} ${label}` : label)
: t(`analytics.${label}`)}
</div>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-sm font-bold">
{payload[0].value} XP
</span>
</div>
</div>
);
}
return null;
};
// Create Reward Mutation
const createRewardMutation = useMutation({
mutationFn: async (newReward: { title: string, description: string, cost: number }) => {
const res = await fetch('/api/rewards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...newReward,
icon: "gift", // Default icon for now
type: "virtual"
})
});
if (!res.ok) throw new Error("Failed to create reward");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
toast({ title: "Reward created!" });
},
onError: () => {
toast({ title: "Failed to create reward", variant: "destructive" });
}
});
const handleCreateReward = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
createRewardMutation.mutate({
title: formData.get('title') as string,
description: formData.get('description') as string,
cost: parseInt(formData.get('cost') as string)
});
};
return (
<div className="space-y-6 pb-20 md:pb-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-1">{t('achievements.title')}</h1>
<p className="text-muted-foreground">{t('achievements.subtitle')}</p>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">{t('achievements.title')}</TabsTrigger>
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Level Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('gamification.level', { level })}</CardTitle>
<Trophy className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{t(`ranks.${rankKey}`)}</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
<span className="text-green-500 flex items-center mr-1">
<TrendingUp className="h-3 w-3 mr-1" /> +15%
</span>
{t('achievements.fromLastWeek')}
</p>
</CardContent>
</Card>
{/* Streak Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('achievements.currentStreak')}</CardTitle>
<Flame className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{t('achievements.days', { count: user.currentStreak })}</div>
<p className="text-xs text-muted-foreground mt-1">
{t('achievements.bestStreak', { count: user.currentStreak })}
</p>
</CardContent>
</Card>
{/* More stats placeholder */}
</div>
<div className="grid gap-4 md:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>{t('achievements.xpActivity')}</CardTitle>
<CardDescription>{t('achievements.xpDescription')}</CardDescription>
</CardHeader>
<CardContent className="pl-2">
<Tabs defaultValue="weekly" className="space-y-4">
<div className="flex items-center justify-end px-4">
<TabsList>
<TabsTrigger value="weekly">{t('achievements.weekly')}</TabsTrigger>
<TabsTrigger value="monthly">{t('achievements.monthly')}</TabsTrigger>
<TabsTrigger value="yearly">{t('achievements.yearly')}</TabsTrigger>
</TabsList>
</div>
<TabsContent value="monthly" className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData}>
<XAxis
dataKey="labelKey"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(val) => {
const translationKey = `analytics.${val}`;
const translated = t(translationKey);
return translated !== translationKey ? translated : `${t('analytics.cw')} ${val}`;
}}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
{monthlyData?.map((entry: any, index: number) => (
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</TabsContent>
<TabsContent value="weekly" className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyData}>
<XAxis
dataKey="labelKey"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(val) => t(`analytics.${val}`)}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
{weeklyData?.map((entry: any, index: number) => (
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</TabsContent>
<TabsContent value="yearly" className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={yearlyData}>
<XAxis
dataKey="labelKey"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(val) => t(`analytics.${val}`)}
/>
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="xp" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} opacity={0.8} />
</BarChart>
</ResponsiveContainer>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('achievements.goals')}</CardTitle>
<CardDescription>{t('achievements.goalsDescription')}</CardDescription>
</div>
<Dialog open={isGoalModalOpen} onOpenChange={setIsGoalModalOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t('achievements.addGoal')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('achievements.createGoal')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateGoal} className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('achievements.goalTitle')}</label>
<Input name="title" placeholder={t('achievements.goalTitlePlaceholder')} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('achievements.targetValue')}</label>
<Input name="target" type="number" placeholder="50" required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('achievements.type')}</label>
<Select name="type" defaultValue="weekly_tasks">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weekly_tasks">{t('achievements.types.weekly_tasks')}</SelectItem>
<SelectItem value="total_xp">{t('achievements.types.total_xp')}</SelectItem>
<SelectItem value="streak">{t('achievements.types.streak')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" className="w-full">{t('achievements.createGoal')}</Button>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="space-y-6">
{goals.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Target className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p className="text-sm">{t('achievements.noGoals')}</p>
</div>
) : (
goals.map((goal) => (
<div key={goal.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{goal.completed ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
<span className={`text-sm font-medium ${goal.completed ? 'line-through text-muted-foreground' : ''}`}>
{goal.title}
</span>
</div>
<span className="text-xs text-muted-foreground">
{goal.current} / {goal.target}
</span>
</div>
<Progress value={(goal.current / goal.target) * 100} className="h-2" />
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="rewards">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="col-span-full mb-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-primary/20">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-primary" />
<CardTitle>{t('rewards.shopTitle')}</CardTitle>
</div>
<CardDescription>Spend your {user.xp} XP on exclusive rewards!</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Reward
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Custom Reward</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateReward} className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<Input name="title" required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Input name="description" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Cost (XP)</label>
<Input name="cost" type="number" required />
</div>
<Button type="submit" className="w-full">Create Reward</Button>
</form>
</DialogContent>
</Dialog>
</CardHeader>
</Card>
{rewards.map(reward => (
<div key={reward.id} className="h-full">
<RewardCard
reward={reward}
userXp={user.xp}
onBuy={handleBuyReward}
isBuying={buyRewardMutation.isPending}
/>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { User } from "@shared/schema";
import { apiRequest, queryClient } from "@/lib/queryClient";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Shield, ShieldAlert, User as UserIcon } from "lucide-react";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function AdminUserManagement() {
const { toast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newUser, setNewUser] = useState({ username: '', email: '', password: '', role: 'user' });
// Queries
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ["/api/admin/users"],
});
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
queryKey: ["/api/admin/settings"],
});
// Mutations
const toggleActiveMutation = useMutation({
mutationFn: (userId: string) => apiRequest("POST", `/api/admin/users/${userId}/toggle-active`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
toast({ title: "User status updated" });
},
onError: (e: Error) => toast({ title: "Failed to update", description: e.message, variant: "destructive" }),
});
const toggleRegistrationMutation = useMutation({
mutationFn: (enabled: boolean) => apiRequest("POST", "/api/admin/settings", { registration_enabled: enabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
toast({ title: "Settings updated" });
},
});
const createUserMutation = useMutation({
mutationFn: (data: typeof newUser) => apiRequest("POST", "/api/admin/users", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
setIsCreateOpen(false);
setNewUser({ username: '', email: '', password: '', role: 'user' });
toast({ title: "User created" });
},
onError: (e: Error) => toast({ title: "Failed to create", description: e.message, variant: "destructive" }),
});
return (
<div className="space-y-6 container mx-auto p-4 max-w-5xl">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold tracking-tight">User Management</h1>
</div>
{/* Global Settings */}
<Card>
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardDescription>Control global access and registration</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="space-y-1">
<p className="font-medium">Public Registration</p>
<p className="text-sm text-muted-foreground">Allow new users to sign up</p>
</div>
<Switch
checked={settings?.registration_enabled}
onCheckedChange={(checked) => toggleRegistrationMutation.mutate(checked)}
/>
</CardContent>
</Card>
{/* User Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Registered Users</CardTitle>
<CardDescription>Manage user accounts and roles</CardDescription>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Username</Label>
<Input value={newUser.username} onChange={e => setNewUser({ ...newUser, username: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={newUser.email} onChange={e => setNewUser({ ...newUser, email: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input type="password" value={newUser.password} onChange={e => setNewUser({ ...newUser, password: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Role</Label>
<select
className="w-full p-2 border rounded-md bg-background"
value={newUser.role}
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Administrator</option>
</select>
</div>
<Button
onClick={() => createUserMutation.mutate(newUser)}
disabled={createUserMutation.isPending}
className="w-full"
>
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
</Button>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>XP / Level</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">Loading users...</TableCell>
</TableRow>
) : users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{user.username}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
</div>
</TableCell>
<TableCell>
{user.role === 'admin' ? (
<Badge variant="default" className="bg-primary/20 text-primary hover:bg-primary/30">
<Shield className="w-3 h-3 mr-1" /> Admin
</Badge>
) : (
<Badge variant="outline">
<UserIcon className="w-3 h-3 mr-1" /> User
</Badge>
)}
</TableCell>
<TableCell>
{user.isActive ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">Active</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
)}
</TableCell>
<TableCell>
{user.xp} XP (Lvl {user.level})
</TableCell>
<TableCell className="text-right">
<Button
variant={user.isActive ? "destructive" : "outline"}
size="sm"
onClick={() => toggleActiveMutation.mutate(user.id)}
disabled={user.role === 'admin' && user.username === 'admin'} // Protect super admin heuristic
>
{user.isActive ? 'Deactivate' : 'Activate'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useLocation } from "wouter";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertUserSchema, InsertUser, loginSchema, registerSchema, LoginUser } from "@shared/schema";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BrainCircuit } from "lucide-react";
export default function AuthPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
queryKey: ["/api/settings/public"],
queryFn: async () => {
const res = await fetch("/api/settings/public");
if (!res.ok) {
throw new Error("Failed to fetch settings");
}
return res.json();
},
});
const loginMutation = useMutation({
mutationFn: async (data: LoginUser) => {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error("Invalid username or password");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Welcome back!" });
},
onError: (error: Error) => {
toast({
title: "Login failed",
description: error.message,
variant: "destructive",
});
},
});
const registerMutation = useMutation({
mutationFn: async (data: InsertUser) => {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Registration failed");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Account created!" });
},
// Error handling is done in the form submission handler to set field errors
});
return (
<div className="min-h-screen grid lg:grid-cols-2">
<div className="hidden lg:flex flex-col justify-center items-center bg-zinc-900 p-12 text-white">
<div className="max-w-md space-y-4 text-center">
<div className="bg-white/10 p-4 rounded-2xl inline-block mb-4 backdrop-blur-sm">
<BrainCircuit className="w-16 h-16 text-primary-foreground" />
</div>
<h1 className="text-4xl font-bold tracking-tight">TaskFlow</h1>
<p className="text-lg text-zinc-400">
Master your productivity with AI-driven task management, gamified
achievements, and intelligent focus modes.
</p>
</div>
</div>
<div className="flex items-center justify-center p-4 bg-background">
<Card className="w-full max-w-md shadow-xl border-border/50">
<CardHeader className="text-center space-y-2">
<div className="lg:hidden mx-auto bg-primary/10 p-3 rounded-xl w-fit mb-2">
<BrainCircuit className="w-8 h-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
<CardDescription>
Sign in to your account
{settings?.registration_enabled && " or create a new one"} to get started
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="login" className="space-y-6">
<TabsList className={`grid w-full ${settings?.registration_enabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
<TabsTrigger value="login">Login</TabsTrigger>
{settings?.registration_enabled && (
<TabsTrigger value="register">Register</TabsTrigger>
)}
</TabsList>
<TabsContent value="login">
<AuthForm
mode="login"
onSubmit={(data) => loginMutation.mutate(data)}
isLoading={loginMutation.isPending}
/>
</TabsContent>
{settings?.registration_enabled && (
<TabsContent value="register">
<AuthForm
mode="register"
onSubmit={(data) => {
registerMutation.mutate(data as InsertUser, {
onError: (error) => {
// We can't access form here directly easily without refactoring,
// but we can pass a callback or handle it in AuthForm if we passed mutation there.
// However, simpler is to catch it here if we want global toast.
// The Requirements say "indicate failure".
// To set FIELD errors, we must be inside the form submit context or have access to form methods.
// Let's refactor AuthForm to handle the mutation itself or return the error?
// Actually, simpler: pass the mutation TO AuthForm so it can handle onError.
}
})
}}
isLoading={registerMutation.isPending}
registerMutation={registerMutation} // Pass mutation to handle errors inside
/>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
</div>
</div>
);
}
function AuthForm({
mode,
onSubmit,
isLoading,
registerMutation,
}: {
mode: "login" | "register";
onSubmit: (data: InsertUser | LoginUser) => void;
isLoading: boolean;
registerMutation?: any; // Type accurately if possible, but 'any' for quick fix avoids generic complexities
}) {
const { toast } = useToast();
const form = useForm<InsertUser>({
resolver: zodResolver(mode === "login" ? loginSchema : registerSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
});
const handleSubmit = (data: InsertUser | LoginUser) => {
if (mode === 'register' && registerMutation) {
registerMutation.mutate(data, {
onError: (error: Error) => {
const msg = error.message.toLowerCase();
if (msg.includes("username")) {
form.setError("username", { type: "manual", message: "Username already exists" });
} else if (msg.includes("email")) {
form.setError("email", { type: "manual", message: "Email already exists" });
} else {
toast({
title: "Registration failed",
description: error.message,
variant: "destructive",
})
}
}
});
} else {
onSubmit(data);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{mode === 'login' ? 'Username or Email' : 'Username'}</FormLabel>
<FormControl>
<Input placeholder={mode === 'login' ? "Enter username or email" : "Choose a username"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{mode === "register" && (
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading
? mode === "login"
? "Logging in..."
: "Creating account..."
: mode === "login"
? "Sign In"
: "Create Account"}
</Button>
</form>
</Form>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Loader2, Trophy, Medal } from "lucide-react";
interface LeaderboardUser {
id: string;
username: string;
xp: number;
level: number;
}
export default function LeaderboardPage() {
const { data: leaderboard, isLoading } = useQuery<LeaderboardUser[]>({
queryKey: ["/api/leaderboard"],
});
if (isLoading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-8">
<div className="flex items-center gap-3">
<div className="bg-yellow-500/20 p-3 rounded-xl">
<Trophy className="h-8 w-8 text-yellow-500" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Leaderboard</h1>
<p className="text-muted-foreground">Top performers in the community</p>
</div>
</div>
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle>Global Rankings</CardTitle>
<CardDescription>Users ranked by total XP (opt-in only)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{leaderboard?.map((user, index) => (
<div
key={user.id}
className={`flex items-center justify-between p-4 rounded-lg border ${index === 0 ? 'bg-yellow-500/10 border-yellow-500/50' :
index === 1 ? 'bg-slate-400/10 border-slate-400/50' :
index === 2 ? 'bg-amber-700/10 border-amber-700/50' :
'bg-card hover:bg-accent/50 transition-colors'
}`}
>
<div className="flex items-center gap-4">
<div className={`flex items-center justify-center w-8 h-8 rounded-full font-bold ${index < 3 ? 'text-foreground' : 'text-muted-foreground'
}`}>
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}
</div>
<div className="flex flex-col">
<span className="font-semibold text-lg">{user.username}</span>
<span className="text-xs text-muted-foreground">Level {user.level}</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-lg text-primary">{user.xp}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider">XP</span>
</div>
</div>
))}
{leaderboard?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No users on the leaderboard yet. Be the first to join in Settings!
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertUserSchema } from "@shared/schema";
import { useLocation } from "wouter";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { ShieldCheck } from "lucide-react";
export default function SetupWizard() {
const [, setLocation] = useLocation();
const { toast } = useToast();
const form = useForm({
resolver: zodResolver(insertUserSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
});
const setupMutation = useMutation({
mutationFn: async (data: any) => {
const res = await apiRequest("POST", "/api/setup", data);
return res.json();
},
onSuccess: (user) => {
queryClient.setQueryData(["/api/user"], user);
setLocation("/");
toast({
title: "Setup Complete",
description: "Super Administrator created successfully.",
});
},
onError: (error: Error) => {
toast({
title: "Setup Failed",
description: error.message,
variant: "destructive"
});
}
});
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-md border-2 border-primary/20 shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto bg-primary/10 p-3 rounded-full w-fit mb-4">
<ShieldCheck className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-2xl">First-Time Setup</CardTitle>
<CardDescription>
Create your Super Administrator account to get started.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => setupMutation.mutate(data))} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Admin Username</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Admin Email</FormLabel>
<FormControl>
<Input type="email" placeholder="admin@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={setupMutation.isPending}>
{setupMutation.isPending ? "Creating Admin..." : "Create Administrator"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
+80 -14
View File
@@ -6,10 +6,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Globe, Tag, Plus, Edit, Trash2, Layers } from 'lucide-react';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck } from 'lucide-react'; // Added ShieldCheck
import { Label } from '@shared/schema';
import { User } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useLocation } from "wouter";
interface SettingsProps {
onNavigateToTemplates: () => void;
@@ -18,16 +20,37 @@ interface SettingsProps {
export default function Settings({ onNavigateToTemplates }: SettingsProps) {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const [, setLocation] = useLocation();
// Fetch user
const { data: user } = useQuery<User>({
queryKey: ['/api/user']
});
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
localStorage.setItem('taskflow-language', lng);
console.log('Language changed to:', lng);
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
localStorage.setItem('taskflow-language', value);
console.log('Language changed to:', value);
};
const privacyMutation = useMutation({
mutationFn: async (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
const res = await apiRequest("PATCH", "/api/user/privacy", updates);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Privacy settings updated" });
},
});
const handlePrivacyUpdate = (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
privacyMutation.mutate(updates);
};
// Fetch labels
@@ -115,6 +138,49 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</h1>
</div>
{/* Account Settings */}
<Card data-testid="card-account-settings">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Account
</CardTitle>
<CardDescription>
Manage your account settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Username</p>
<p className="text-sm text-muted-foreground">{user?.username || 'Loading...'}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">User ID</p>
<p className="text-sm text-muted-foreground font-mono">{user?.id || '...'}</p>
</div>
</CardContent>
</Card>
{/* Admin Settings */}
{user?.role === 'admin' && (
<Card className="border-primary/50 bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-primary" />
Administration
</CardTitle>
<CardDescription>
System-wide settings and user management
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setLocation("/admin/users")} className="w-full sm:w-auto">
Manage Users & Registration
</Button>
</CardContent>
</Card>
)}
{/* Language Settings */}
<Card data-testid="card-language-settings">
<CardHeader>
@@ -197,8 +263,8 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
@@ -210,7 +276,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
>
{t('settings.labels.cancel')}
</Button>
<Button
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
@@ -232,15 +298,15 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{labels.map((label) => (
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
style={{ borderLeft: `4px solid ${label.color}` }}
data-testid={`label-${label.id}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
@@ -305,7 +371,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</CardDescription>
</CardHeader>
<CardContent>
<Button
<Button
onClick={onNavigateToTemplates}
data-testid="button-manage-templates"
>