feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-12 08:35:48 +01:00
parent ccfb674318
commit 5d8976b1cd
58 changed files with 7867 additions and 844 deletions
+20 -3
View File
@@ -36,11 +36,15 @@ import { AppSidebar } from "./components/AppSidebar"
import { CommandPalette } from './components/CommandPalette';
import PomodoroOverlay from './components/PomodoroOverlay';
import AuthPage from "@/pages/AuthPage";
import SettingsPage from "@/pages/settings";
import LeaderboardPage from "@/pages/LeaderboardPage";
import SetupWizard from "@/pages/SetupWizard";
import AdminUserManagement from "@/pages/AdminUserManagement";
import AdminSettings from "@/pages/AdminSettings";
import NotFound from "@/pages/not-found";
import { AiChat } from "@/components/AiChat";
import ForgotPasswordPage from "@/pages/ForgotPasswordPage";
import ResetPasswordPage from "@/pages/ResetPasswordPage";
import { useQuery } from "@tanstack/react-query";
import { User } from "@shared/schema";
@@ -229,7 +233,13 @@ function App() {
}
if (!user) {
return <AuthPage />;
return (
<Switch>
<Route path="/forgot-password" component={ForgotPasswordPage} />
<Route path="/reset-password" component={ResetPasswordPage} />
<Route component={AuthPage} />
</Switch>
);
}
return (
@@ -312,13 +322,19 @@ function App() {
<Route path="/achievements">
<AchievementsPage user={user} />
</Route>
<Route path="/leaderboard">
<LeaderboardPage />
</Route>
<Route path="/settings">
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
</Route>
{/* Admin Route */}
{user.role === 'admin' && (
<Route path="/admin/users" component={AdminUserManagement} />
<>
<Route path="/admin/users" component={AdminUserManagement} />
<Route path="/admin/settings" component={AdminSettings} />
</>
)}
<Route component={NotFound} />
@@ -354,6 +370,7 @@ function App() {
/>
<Toaster />
{user?.aiEnabled && <AiChat />}
<TaskCreationModal
isOpen={isCreateModalOpen}
+124
View File
@@ -0,0 +1,124 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardFooter, CardHeader } from '@/components/ui/card';
import { Bot, X, Send, Sparkles, Loader2 } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { cn } from '@/lib/utils';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function AiChat() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const mutation = useMutation({
mutationFn: async (msgs: Message[]) => {
const res = await apiRequest("POST", "/api/ai/chat", { messages: msgs });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed");
}
return res.json();
},
onSuccess: (data) => {
setMessages(prev => [...prev, data]);
},
onError: (err: Error) => {
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.error') + ": " + err.message }]);
}
});
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isOpen, mutation.isPending]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || mutation.isPending) return;
const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
setMessages(newMsgs);
setInput('');
mutation.mutate(newMsgs);
};
if (!isOpen) {
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
size="icon"
>
<Sparkles className="h-6 w-6 text-white animate-pulse" />
</Button>
);
}
return (
<Card className="fixed bottom-24 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
<CardHeader className="p-4 border-b bg-primary/5 flex flex-row items-center justify-between shrink-0">
<div className="flex items-center gap-2 font-semibold">
<Bot className="w-5 h-5 text-primary" />
{t('ai.title')}
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsOpen(false)}>
<X className="w-4 h-4" />
</Button>
</CardHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4" ref={scrollRef}>
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-10">
<Bot className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('ai.welcome')}</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={cn("flex w-full", msg.role === 'user' ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[80%] rounded-2xl px-4 py-2 text-sm shadow-sm whitespace-pre-wrap",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-br-none"
: "bg-muted text-foreground rounded-bl-none"
)}>
{msg.content}
</div>
</div>
))}
{mutation.isPending && (
<div className="flex w-full justify-start">
<div className="bg-muted rounded-2xl rounded-bl-none px-4 py-2 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs text-muted-foreground">{t('ai.thinking')}</span>
</div>
</div>
)}
</div>
<CardFooter className="p-3 border-t bg-background shrink-0">
<form onSubmit={handleSubmit} className="flex w-full gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('ai.placeholder')}
className="bg-muted/50 focus-visible:ring-primary/50"
/>
<Button type="submit" size="icon" disabled={mutation.isPending || !input.trim()}>
<Send className="w-4 h-4" />
</Button>
</form>
</CardFooter>
</Card>
);
}
+21 -11
View File
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award } from 'lucide-react';
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
@@ -96,21 +97,30 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
{state !== 'collapsed' && user && (
<GamificationBar xp={user.xp} level={user.level} streak={user.currentStreak} />
)}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
<div className={`p-4 ${state === 'collapsed'
? 'flex flex-col items-center justify-center gap-4'
: 'grid grid-cols-3 items-center'
}`}>
<div className={state === 'collapsed' ? '' : 'justify-self-start'}>
<ThemeToggle />
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-center'}>
<Button
variant="ghost"
size="icon"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
title="Logout"
>
<LogOut className={state === 'collapsed' ? 'size-5' : 'size-4'} />
{state !== 'collapsed' && <span className="font-medium ml-2">Logout</span>}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className={`p-4 flex items-center ${state === 'collapsed' ? 'justify-center flex-col gap-4' : 'justify-between'}`}>
<ThemeToggle />
<SidebarTrigger className={state === 'collapsed' ? '' : 'ml-auto'} />
<LogOut className="size-4" />
</Button>
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-end'}>
<SidebarTrigger />
</div>
</div>
</SidebarFooter>
<SidebarRail />
@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "auth.currentPasswordRequired"),
newPassword: z.string().min(6, "auth.passwordMinLength"),
confirmNewPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmNewPassword, {
message: "validation.passwordMatch",
path: ["confirmNewPassword"],
});
type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
interface ChangePasswordModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const form = useForm<ChangePasswordFormValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
},
});
const mutation = useMutation({
mutationFn: async (data: ChangePasswordFormValues) => {
const res = await apiRequest("PATCH", "/api/user/password", {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to change password");
}
return res.json();
},
onSuccess: () => {
toast({ title: t('auth.passwordChangedSuccess') });
form.reset();
onOpenChange(false);
},
onError: (error: Error) => {
// If error mentions current password, set field error
if (error.message.toLowerCase().includes("current password")) {
form.setError("currentPassword", { message: t('auth.incorrectCurrentPassword') });
} else {
toast({
title: t('auth.changePasswordFailed'),
description: error.message,
variant: "destructive",
});
}
},
});
const onSubmit = (data: ChangePasswordFormValues) => {
mutation.mutate(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('auth.changePassword')}</DialogTitle>
<DialogDescription>
{t('auth.changePasswordDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.currentPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.currentPassword?.message && t(form.formState.errors.currentPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.newPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.newPassword?.message && t(form.formState.errors.newPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.confirmPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.confirmNewPassword?.message && t(form.formState.errors.confirmNewPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('auth.changing') : t('auth.changePassword')}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+240 -222
View File
@@ -34,7 +34,11 @@ const convertProjectTaskToTask = (projectTask: ProjectTask, projectId: string):
isTracking: false,
projectId: projectId,
notes: projectTask.notes || null,
labelId: projectTask.labelId || null
labelId: projectTask.labelId || null,
energyLevel: 'medium',
estimatedDuration: (projectTask.estimatedHours || 0) * 60,
dependencies: [],
userId: null
};
};
@@ -121,8 +125,8 @@ const defaultProjects: Project[] = [
}
];
export default function ProjectTemplate({
onCreateFromTemplate,
export default function ProjectTemplate({
onCreateFromTemplate,
onCreateTemplate,
onCreateTasks,
onNavigateToSettings
@@ -143,33 +147,33 @@ export default function ProjectTemplate({
const [clearHistory, setClearHistory] = useState(false);
const [isEndDateCalendarOpen, setIsEndDateCalendarOpen] = useState(false);
const [closedProjectsFilter, setClosedProjectsFilter] = useState('');
// Task management state
const [projectTasks, setProjectTasks] = useState<ProjectTask[]>([]);
const [isEditingTask, setIsEditingTask] = useState(false);
const [editingTaskIndex, setEditingTaskIndex] = useState<number | null>(null);
const [taskForm, setTaskForm] = useState<Partial<ProjectTask>>({});
// Label management state
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
// Task details modal state
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
const [selectedTaskForDetails, setSelectedTaskForDetails] = useState<ProjectTask | null>(null);
const queryClient = useQueryClient();
// Fetch labels
const { data: labels = [], isLoading: labelsLoading } = useQuery<Label[]>({
queryKey: ['/api/labels'],
});
// Create label mutation
const createLabelMutation = useMutation({
mutationFn: (data: { name: string; color: string }) =>
mutationFn: (data: { name: string; color: string }) =>
apiRequest('POST', '/api/labels', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
@@ -179,7 +183,7 @@ export default function ProjectTemplate({
setEditingLabel(null);
}
});
// Update label mutation
const updateLabelMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; color?: string }) =>
@@ -192,7 +196,7 @@ export default function ProjectTemplate({
setEditingLabel(null);
}
});
// Delete label mutation
const deleteLabelMutation = useMutation({
mutationFn: (id: string) => apiRequest('DELETE', `/api/labels/${id}`),
@@ -213,19 +217,19 @@ export default function ProjectTemplate({
plannedEndDate: newPlannedEndDate,
createdAt: new Date()
};
// Add project to projects list
setProjects(currentProjects => [...currentProjects, newProject]);
// Convert project tasks to global tasks and add them to global state
if (projectTasks.length > 0 && onCreateTasks) {
const globalTasks = projectTasks.map(task =>
const globalTasks = projectTasks.map(task =>
convertProjectTaskToTask(task, newProjectId)
);
onCreateTasks(globalTasks);
console.log('Added', globalTasks.length, 'tasks to global state for project:', newProject.name);
}
// Reset form state
setNewProjectName('');
setNewProjectDescription('');
@@ -249,7 +253,7 @@ export default function ProjectTemplate({
};
const handleProjectStatusChange = (projectId: string, newStatus: Project['status']) => {
setProjects(currentProjects => currentProjects.map(project =>
setProjects(currentProjects => currentProjects.map(project =>
project.id === projectId ? { ...project, status: newStatus } : project
));
};
@@ -282,15 +286,15 @@ export default function ProjectTemplate({
if (restartingProject && newEndDate) {
// Create a new project as a duplicate/copy of the closed project
const newProjectId = Date.now().toString();
const duplicatedTasks = clearHistory
? []
const duplicatedTasks = clearHistory
? []
: restartingProject.tasks.map(task => ({
...task,
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
status: 'todo' as const // Reset all tasks to todo status
}));
...task,
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
status: 'todo' as const // Reset all tasks to todo status
}));
const duplicatedProject: Project = {
...restartingProject,
id: newProjectId, // New unique ID
@@ -301,19 +305,19 @@ export default function ProjectTemplate({
createdAt: new Date(), // Set new creation date
tasks: duplicatedTasks
};
// Add the new project to the projects array without removing the original
setProjects(currentProjects => [...currentProjects, duplicatedProject]);
// Convert project tasks to global tasks and add them to global state
if (duplicatedTasks.length > 0 && onCreateTasks) {
const globalTasks = duplicatedTasks.map(task =>
const globalTasks = duplicatedTasks.map(task =>
convertProjectTaskToTask(task, newProjectId)
);
onCreateTasks(globalTasks);
console.log('Added', globalTasks.length, 'tasks to global state for duplicated project:', duplicatedProject.name);
}
setIsRestartProjectOpen(false);
setRestartingProject(null);
setNewEndDate(undefined);
@@ -325,7 +329,7 @@ export default function ProjectTemplate({
const getFilteredClosedProjects = () => {
const closedProjects = projects.filter(project => project.status === 'finished');
if (!closedProjectsFilter) return closedProjects;
return closedProjects.filter(project =>
project.name.toLowerCase().includes(closedProjectsFilter.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(closedProjectsFilter.toLowerCase()))
@@ -343,7 +347,7 @@ export default function ProjectTemplate({
estimatedHours: taskForm.estimatedHours || 0,
labelId: taskForm.labelId
};
setProjectTasks([...projectTasks, newTask]);
setTaskForm({});
setIsEditingTask(false);
@@ -359,10 +363,10 @@ export default function ProjectTemplate({
if (editingTaskIndex !== null) {
const currentTask = projectTasks[editingTaskIndex];
const updatedTasks = [...projectTasks];
// Check if this is a new task (temporary ID) or existing task
const isNewTask = currentTask.id.startsWith('temp-');
updatedTasks[editingTaskIndex] = {
id: isNewTask ? Date.now().toString() : currentTask.id,
title: taskForm.title || 'New Task',
@@ -372,10 +376,10 @@ export default function ProjectTemplate({
estimatedHours: taskForm.estimatedHours || 0,
labelId: taskForm.labelId
};
setProjectTasks(updatedTasks);
}
setTaskForm({});
setIsEditingTask(false);
setEditingTaskIndex(null);
@@ -394,7 +398,7 @@ export default function ProjectTemplate({
setProjectTasks(updatedTasks);
}
}
setTaskForm({});
setIsEditingTask(false);
setEditingTaskIndex(null);
@@ -410,7 +414,7 @@ export default function ProjectTemplate({
status: 'todo',
estimatedHours: 0
};
const newTasks = [...projectTasks, tempTask];
setProjectTasks(newTasks);
setEditingTaskIndex(newTasks.length - 1);
@@ -452,9 +456,9 @@ export default function ProjectTemplate({
if (projectTaskIndex !== -1) {
updatedProject.tasks[projectTaskIndex] = updatedTask;
setSelectedProject(updatedProject);
// Update the projects array
setProjects(currentProjects => currentProjects.map(p =>
setProjects(currentProjects => currentProjects.map(p =>
p.id === selectedProject.id ? updatedProject : p
));
}
@@ -467,36 +471,36 @@ export default function ProjectTemplate({
...selectedProject,
tasks: projectTasks
};
setProjects(currentProjects => currentProjects.map(p =>
setProjects(currentProjects => currentProjects.map(p =>
p.id === selectedProject.id ? updatedProject : p
));
setSelectedProject(null);
setProjectTasks([]);
setIsEditProjectOpen(false);
console.log('Updated project:', updatedProject.name, 'with', projectTasks.length, 'tasks');
}
};
// Label management functions
const handleSaveLabel = () => {
if (!labelName.trim()) return;
if (editingLabel) {
updateLabelMutation.mutate({ id: editingLabel.id, name: labelName.trim(), color: labelColor });
} else {
createLabelMutation.mutate({ name: labelName.trim(), color: labelColor });
}
};
const handleEditLabel = (label: Label) => {
setEditingLabel(label);
setLabelName(label.name);
setLabelColor(label.color);
setIsLabelDialogOpen(true);
};
const handleDeleteLabel = (id: string) => {
if (confirm(t('projectTemplate.deleteConfirm'))) {
deleteLabelMutation.mutate(id);
@@ -542,7 +546,7 @@ export default function ProjectTemplate({
{t('projectTemplate.title')}
</h2>
</div>
<Button
<Button
onClick={() => setIsCreateProjectOpen(true)}
data-testid="button-create-project"
size="lg"
@@ -564,7 +568,7 @@ export default function ProjectTemplate({
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
{projectColumns.map((column) => {
const columnProjects = getProjectsByStatus(column.status);
return (
<Card
key={column.id}
@@ -581,7 +585,7 @@ export default function ProjectTemplate({
{columnProjects.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[200px] sm:min-h-[300px]">
{columnProjects.map((project) => (
<Card
@@ -589,9 +593,8 @@ export default function ProjectTemplate({
draggable
onDragStart={() => handleDragStart(project.id)}
onDragEnd={handleDragEnd}
className={`p-3 sm:p-4 cursor-move hover-elevate transition-all ${
draggedProject === project.id ? 'opacity-50 scale-95' : ''
}`}
className={`p-3 sm:p-4 cursor-move hover-elevate transition-all ${draggedProject === project.id ? 'opacity-50 scale-95' : ''
}`}
data-testid={`project-card-${project.id}`}
>
<div className="space-y-3">
@@ -606,7 +609,7 @@ export default function ProjectTemplate({
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -662,7 +665,7 @@ export default function ProjectTemplate({
</div>
</Card>
))}
{columnProjects.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No projects in {column.title.toLowerCase()}
@@ -686,7 +689,7 @@ export default function ProjectTemplate({
data-testid="input-filter-closed-projects"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{getFilteredClosedProjects().map((project) => (
<Card key={project.id} className="p-4 hover-elevate" data-testid={`closed-project-card-${project.id}`}>
@@ -702,7 +705,7 @@ export default function ProjectTemplate({
</p>
)}
</div>
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
Finished
</Badge>
@@ -739,7 +742,7 @@ export default function ProjectTemplate({
</div>
</Card>
))}
{getFilteredClosedProjects().length === 0 && (
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{closedProjectsFilter ? 'No closed projects match your filter' : 'No closed projects yet'}
@@ -763,7 +766,7 @@ export default function ProjectTemplate({
{restartingProject.tasks.length} tasks {getTotalEstimatedHours(restartingProject)}h estimated
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Planned End Date</label>
<Popover open={isEndDateCalendarOpen} onOpenChange={setIsEndDateCalendarOpen}>
@@ -846,7 +849,7 @@ export default function ProjectTemplate({
<DialogHeader>
<DialogTitle>{t('projectTemplate.createProject')}</DialogTitle>
<DialogDescription>
{t('projectTemplate.description')}
{t('projectTemplate.pageDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
@@ -972,7 +975,7 @@ export default function ProjectTemplate({
</div>
</Card>
))}
{projectTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
{t('projectTemplate.noLabelsDescription')}
@@ -987,34 +990,34 @@ export default function ProjectTemplate({
<h4 className="font-semibold text-sm">
{editingTaskIndex !== null ? t('projectTemplate.edit') : t('projectTemplate.addTask')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.taskTitle')}</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
placeholder={t('projectTemplate.enterTaskTitle')}
data-testid="input-task-title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.description')}</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
placeholder={t('projectTemplate.enterTaskDescription')}
data-testid="input-task-description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.priority')}</label>
<Select
value={taskForm.priority || 'medium'}
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({...taskForm, priority: value})
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({ ...taskForm, priority: value })
}
>
<SelectTrigger data-testid="select-task-priority">
@@ -1027,13 +1030,13 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
<Select
value={taskForm.status || 'todo'}
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({...taskForm, status: value})
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({ ...taskForm, status: value })
}
>
<SelectTrigger data-testid="select-task-status">
@@ -1046,20 +1049,20 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.estimatedHours')}</label>
<Input
type="number"
value={taskForm.estimatedHours || ''}
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
placeholder="0"
min="0"
data-testid="input-task-hours"
/>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
@@ -1145,7 +1148,7 @@ export default function ProjectTemplate({
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
<Select
value={selectedProject.status}
onValueChange={(value: Project['status']) =>
onValueChange={(value: Project['status']) =>
setSelectedProject({ ...selectedProject, status: value })
}
>
@@ -1172,8 +1175,8 @@ export default function ProjectTemplate({
data-testid="button-edit-planned-end-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedProject.plannedEndDate
? selectedProject.plannedEndDate.toLocaleDateString()
{selectedProject.plannedEndDate
? selectedProject.plannedEndDate.toLocaleDateString()
: t('projectTemplate.selectPlannedEndDate')}
</Button>
</PopoverTrigger>
@@ -1230,30 +1233,30 @@ export default function ProjectTemplate({
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.taskTitle')}</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
placeholder={t('projectTemplate.enterTaskTitle')}
className="text-sm"
data-testid={`input-inline-task-title-${index}`}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.description')}</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
placeholder={t('projectTemplate.enterTaskDescription')}
className="text-sm"
data-testid={`input-inline-task-description-${index}`}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.priority')}</label>
<Select
value={taskForm.priority || 'medium'}
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({...taskForm, priority: value})
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({ ...taskForm, priority: value })
}
>
<SelectTrigger className="text-sm" data-testid={`select-inline-task-priority-${index}`}>
@@ -1266,13 +1269,13 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.status')}</label>
<Select
value={taskForm.status || 'todo'}
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({...taskForm, status: value})
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({ ...taskForm, status: value })
}
>
<SelectTrigger className="text-sm" data-testid={`select-inline-task-status-${index}`}>
@@ -1285,13 +1288,13 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.estimatedHours')}</label>
<Input
type="number"
value={taskForm.estimatedHours || ''}
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
placeholder="0"
min="0"
className="text-sm max-w-32"
@@ -1299,7 +1302,7 @@ export default function ProjectTemplate({
/>
</div>
</div>
<div className="flex items-center gap-2 justify-end pt-2 border-t">
<Button
variant="outline"
@@ -1322,8 +1325,8 @@ export default function ProjectTemplate({
) : (
// Display Mode
<div className="flex items-center justify-between">
<div
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
<div
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
onClick={() => handleOpenTaskDetails(task)}
data-testid={`clickable-task-${index}`}
>
@@ -1398,7 +1401,7 @@ export default function ProjectTemplate({
)}
</Card>
))}
{projectTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
{t('projectTemplate.noTasksInProject')}
@@ -1436,150 +1439,165 @@ export default function ProjectTemplate({
</Dialog>
<TabsContent value="labels" className="space-y-6">
{/* Labels Section Header */}
<div className="flex items-center justify-between">
<h3 className="text-md font-medium">{t('settings.labels.title')}</h3>
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" data-testid="button-create-label">
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? t('projectTemplate.editLabel') : t('projectTemplate.createNewLabel')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('projectTemplate.labelNamePlaceholder')}
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
data-testid="input-label-name"
/>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="color"
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
className="w-12 h-8 rounded border cursor-pointer"
data-testid="input-label-color"
/>
<Input
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
data-testid="input-label-color-text"
/>
{/* Labels Section Header */}
<div className="flex items-center justify-between">
<h3 className="text-md font-medium">{t('settings.labels.title')}</h3>
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" data-testid="button-create-label">
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? t('projectTemplate.editLabel') : t('projectTemplate.createNewLabel')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('projectTemplate.labelNamePlaceholder')}
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
data-testid="input-label-name"
/>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="color"
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
className="w-12 h-8 rounded border cursor-pointer"
data-testid="input-label-color"
/>
<Input
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
data-testid="input-label-color-text"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
}}
className="flex-1"
data-testid="button-cancel-label"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? t('projectTemplate.update') : t('projectTemplate.create')}
</Button>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
}}
className="flex-1"
data-testid="button-cancel-label"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? t('projectTemplate.update') : t('projectTemplate.create')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</DialogContent>
</Dialog>
</div>
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">{t('projectTemplate.loadingLabels')}</div>
</div>
) : (
<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"
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
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
</div>
<div className="flex items-center gap-1">
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">{t('projectTemplate.loadingLabels')}</div>
</div>
) : (
<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"
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
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditLabel(label)}
className="w-6 h-6"
data-testid={`button-edit-${label.id}`}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteLabel(label.id)}
className="w-6 h-6 text-destructive hover:text-destructive"
data-testid={`button-delete-${label.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</Card>
))}
{labels.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="font-medium text-muted-foreground mb-2">{t('projectTemplate.noLabelsYet')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('projectTemplate.noLabelsDescription')}
</p>
<Button
variant="ghost"
size="icon"
onClick={() => handleEditLabel(label)}
className="w-6 h-6"
data-testid={`button-edit-${label.id}`}
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteLabel(label.id)}
className="w-6 h-6 text-destructive hover:text-destructive"
data-testid={`button-delete-${label.id}`}
>
<Trash2 className="w-3 h-3" />
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</div>
</div>
</Card>
))}
{labels.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="font-medium text-muted-foreground mb-2">{t('projectTemplate.noLabelsYet')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('projectTemplate.noLabelsDescription')}
</p>
<Button
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
)}
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
{/* Task Details Modal */}
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleCloseTaskDetails}
task={selectedTaskForDetails}
onSave={handleSaveTaskDetails}
task={selectedTaskForDetails ? convertProjectTaskToTask(selectedTaskForDetails, selectedProject?.id || 'temp') : null}
onSave={(updatedTask) => {
// Convert back to ProjectTask
const projectTask: ProjectTask = {
id: selectedTaskForDetails?.id || updatedTask.id, // Keep original ID if possible
title: updatedTask.title,
description: updatedTask.description || undefined,
status: updatedTask.status as any,
priority: updatedTask.priority as any,
estimatedHours: updatedTask.estimatedDuration ? Math.round(updatedTask.estimatedDuration / 60) : 0,
labelId: updatedTask.labelId || undefined,
notes: updatedTask.notes || undefined,
timeTracked: updatedTask.timeTracked,
timeEntries: [] // Simplify for now
};
handleSaveTaskDetails(projectTask);
}}
labels={labels}
/>
</div>
+123
View File
@@ -0,0 +1,123 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { User } from "@shared/schema";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, Share2, Search, Users } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslation } from "react-i18next";
interface ShareAccessModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareAccessModal({ open, onOpenChange }: ShareAccessModalProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const { data: users, isLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: ["/api/users/search", debouncedQuery],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
if (!res.ok) throw new Error("Failed to search users");
return res.json();
},
enabled: debouncedQuery.length >= 2,
});
const shareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("POST", `/api/users/share-all`, { targetUserId });
return res.json();
},
onSuccess: () => {
toast({ title: t('share.successAccess'), description: t('share.successAccessDesc') });
onOpenChange(false);
},
onError: () => {
toast({ title: t('share.errorAccess'), variant: "destructive" });
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('share.allTasksTitle')}
</DialogTitle>
<DialogDescription>
{t('share.allTasksDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('share.searchPlaceholder')}
className="pl-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
// Simple manual debounce simulation for UI response, real query uses effect or direct set
// For consistency with ShareTaskModal, we'll just set it
}}
onKeyUp={() => setDebouncedQuery(searchQuery)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
{isLoading && debouncedQuery.length >= 2 ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : users && users.length > 0 ? (
<div className="space-y-2">
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between p-2 hover:bg-muted/50 rounded-lg transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
</div>
<Button
size="sm"
onClick={() => shareMutation.mutate(user.id)}
disabled={shareMutation.isPending}
>
{shareMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Share2 className="w-4 h-4 mr-2" />
)}
{t('share.grantAccess')}
</Button>
</div>
))}
</div>
) : debouncedQuery.length >= 2 ? (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.noUsers')}
</div>
) : (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.typeToSearch')}
</div>
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { X, UserPlus, Users } from 'lucide-react';
interface ShareLabelModalProps {
label: Label | null;
open: boolean;
onOpenChange: (open: boolean) => void;
currentUser: any;
}
interface LabelShare {
userId: string;
username: string;
permission: 'read' | 'write' | 'admin';
}
export function ShareLabelModal({ label, open, onOpenChange, currentUser }: ShareLabelModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [newUsername, setNewUsername] = useState('');
const [newPermission, setNewPermission] = useState<'read' | 'write'>('read');
const { data: shares = [], isLoading } = useQuery<LabelShare[]>({
queryKey: [`/api/labels/${label?.id}/share`],
enabled: !!label && open,
});
const shareMutation = useMutation({
mutationFn: (data: { username: string; permission: string }) =>
apiRequest('POST', `/api/labels/${label?.id}/share`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
setNewUsername('');
toast({ title: t('settings.labels.shareSuccess') });
},
onError: (e: Error) => {
toast({
title: t('settings.labels.shareFailed'),
description: e.message,
variant: 'destructive'
});
}
});
const unshareMutation = useMutation({
mutationFn: (userId: string) =>
apiRequest('DELETE', `/api/labels/${label?.id}/share/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
toast({ title: t('settings.labels.unshareSuccess') });
},
});
const handleShare = (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim()) return;
shareMutation.mutate({ username: newUsername, permission: newPermission });
};
if (!label) return null;
// Only creator can share (enforced by backend, but UI check is good too)
const isCreator = label.creatorId === currentUser?.id;
const isSystem = label.creatorId === null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('settings.labels.shareLabel')}: {label.name}
</DialogTitle>
<DialogDescription>
{t('settings.labels.shareDescription')}
</DialogDescription>
</DialogHeader>
{isSystem ? (
<div className="p-4 bg-muted rounded-md text-center text-sm text-muted-foreground">
{t('settings.labels.systemLabelNoShare')}
</div>
) : !isCreator ? (
<div className="p-4 bg-yellow-500/10 text-yellow-500 border border-yellow-500/20 rounded-md text-center text-sm">
{t('settings.labels.onlyOwnerCanShare')}
<p className="text-xs text-muted-foreground mt-1">
{t('settings.labels.owner')}: {t('common.unknown')}
</p>
</div>
) : (
<div className="space-y-6">
{/* Add Collaborator Form */}
<form onSubmit={handleShare} className="flex gap-2 items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">{t('settings.labels.addCollaborator')}</label>
<div className="flex gap-2">
<Input
placeholder={t('settings.labels.usernamePlaceholder')}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
<Select
value={newPermission}
onValueChange={(v: 'read' | 'write') => setNewPermission(v)}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">{t('settings.labels.permRead')}</SelectItem>
<SelectItem value="write">{t('settings.labels.permWrite')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" disabled={!newUsername || shareMutation.isPending}>
<UserPlus className="w-4 h-4" />
</Button>
</form>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">{t('settings.labels.accessList')}</h4>
{isLoading ? (
<div className="text-sm text-center py-4">{t('common.loading')}</div>
) : shares.length === 0 ? (
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/30 rounded-md border border-dashed">
{t('settings.labels.noCollaborators')}
</div>
) : (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{shares.map((share) => (
<div key={share.userId} className="flex items-center justify-between p-2 rounded-md bg-muted/50 border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
{share.username.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{share.username}</p>
<p className="text-xs text-muted-foreground capitalize">{t(`settings.labels.perm${share.permission.charAt(0).toUpperCase() + share.permission.slice(1)}`)}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => unshareMutation.mutate(share.userId)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { User } from "@shared/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Loader2, Share2, Check, Search } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslation } from "react-i18next";
interface ShareTaskModalProps {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareTaskModal({ taskId, open, onOpenChange }: ShareTaskModalProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [userToConfirm, setUserToConfirm] = useState<{ id: string; username: string } | null>(null);
const { data: users, isLoading: searchLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: ["/api/users/search", debouncedQuery],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
if (!res.ok) throw new Error("Failed to search users");
return res.json();
},
enabled: debouncedQuery.length >= 2,
});
const { data: sharedUsers, refetch: refetchShared, isLoading: sharedLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: [`/api/tasks/${taskId}/shared-users`], // Needs to be dependent on taskId
enabled: open, // Only fetch when open
});
const shareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("POST", `/api/tasks/${taskId}/share`, { targetUserId });
return res.json();
},
onSuccess: () => {
toast({ title: t('share.successTask') });
setUserToConfirm(null);
setSearchQuery("");
setDebouncedQuery(""); // Clear search
refetchShared(); // Update list
},
onError: () => {
toast({ title: t('share.errorTask'), variant: "destructive" });
},
});
const unshareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("DELETE", `/api/tasks/${taskId}/share/${targetUserId}`);
return res.json();
},
onSuccess: () => {
toast({ title: t('share.removeSuccess') });
refetchShared();
},
onError: () => {
toast({ title: t('share.errorTask'), variant: "destructive" });
},
});
// Confirmation State
const confirmShare = () => {
if (userToConfirm) {
shareMutation.mutate(userToConfirm.id);
}
};
return (
<Dialog open={open} onOpenChange={(val) => {
if (!val) {
setUserToConfirm(null); // Reset on close
setSearchQuery("");
setDebouncedQuery("");
}
onOpenChange(val);
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="w-5 h-5" />
{t('share.taskTitle')}
</DialogTitle>
</DialogHeader>
{userToConfirm ? (
<div className="space-y-4 py-4">
<div className="bg-muted/50 p-4 rounded-lg text-center">
<Avatar className="h-16 w-16 mx-auto mb-2">
<AvatarFallback className="text-lg">{userToConfirm.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<h3 className="font-semibold text-lg">{userToConfirm.username}</h3>
<p className="text-muted-foreground mt-2">
{t('share.confirmShareDesc', { username: userToConfirm.username })}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setUserToConfirm(null)}>
{t('share.cancel')}
</Button>
<Button onClick={confirmShare} disabled={shareMutation.isPending}>
{shareMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t('share.confirm')}
</Button>
</div>
</div>
) : (
<div className="space-y-6 py-4">
{/* Current Shared Users */}
<div>
<h4 className="text-sm font-medium mb-3">{t('share.sharedWith')}</h4>
<div className="space-y-2">
{sharedLoading ? (
<div className="flex justify-center py-2"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /></div>
) : sharedUsers && sharedUsers.length > 0 ? (
<div className="max-h-[150px] overflow-y-auto space-y-2 pr-1">
{sharedUsers.map(user => (
<div key={user.id} className="flex items-center justify-between p-2 bg-muted/40 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-sm">{user.username}</span>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => unshareMutation.mutate(user.id)}
disabled={unshareMutation.isPending}
>
<span className="text-xs">{t('share.unshare')}</span>
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic pl-1">Not shared with anyone yet.</p>
)}
</div>
</div>
<div className="relative">
<div className="absolute inset-x-0 border-t my-2" />
</div>
{/* Search to Add */}
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('share.searchPlaceholder')}
className="pl-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
// Simple manual debounce for now
}}
onKeyUp={(e) => {
// Debounce could be improved, but sufficient
setDebouncedQuery(searchQuery);
}}
/>
</div>
<ScrollArea className="h-[180px] rounded-md border p-2">
{searchLoading && debouncedQuery.length >= 2 ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : users && users.length > 0 ? (
<div className="space-y-1">
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-2 hover:bg-muted cursor-pointer rounded-lg transition-colors"
onClick={() => setUserToConfirm(user)}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
</div>
<Button size="icon" variant="ghost" className="h-8 w-8">
<Share2 className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
))}
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).length === 0 && (
<div className="text-center p-4 text-sm text-muted-foreground">
No new users found to share with.
</div>
)}
</div>
) : debouncedQuery.length >= 2 ? (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.noUsers')}
</div>
) : (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.typeToSearch')}
</div>
)}
</ScrollArea>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+138 -15
View File
@@ -17,7 +17,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X } from "lucide-react";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock } from "lucide-react";
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
@@ -43,9 +43,85 @@ interface TaskCardProps {
isDragging?: boolean;
}
import { ShareTaskModal } from './ShareTaskModal';
import { Share2, Users } from "lucide-react";
import { User } from "@shared/schema";
function SharedTaskIcon({ task }: { task: Task }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
const isOwner = user?.id === task.userId;
// If owner, fetch shared users
const { data: sharedUsers } = useQuery<any[]>({
queryKey: [`/api/tasks/${task.id}/shared-users`],
enabled: !!isOwner && !!task.id,
retry: false
});
if (!user) return null;
// Case 1: Owner and task is shared
if (isOwner && sharedUsers && sharedUsers.length > 0) {
const names = sharedUsers.map(u => u.username).join(", ");
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900 cursor-help">
<Share2 className="w-3 h-3 text-indigo-600 dark:text-indigo-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWith')}: {names}</p>
</TooltipContent>
</Tooltip>
);
}
// Case 2: Not owner (Shared WITH me)
if (!isOwner && task.userId) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 dark:bg-orange-900 cursor-help">
<Users className="w-3 h-3 text-orange-600 dark:text-orange-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWithMe') || "Shared with me"}</p>
</TooltipContent>
</Tooltip>
);
}
return null;
}
function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
// Only owner can share
if (user?.id !== task.userId) return null;
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Share2 className="w-4 h-4 mr-2" />
{t('share.taskTitle')}
</DropdownMenuItem>
);
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
const { t } = useTranslation();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const handleAIMagic = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -66,6 +142,14 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
}
};
const { data: allTasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
staleTime: 5 * 60 * 1000,
});
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
const isBlocked = blockingTasks.length > 0;
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
@@ -116,6 +200,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
const handleToggleComplete = (e: React.MouseEvent) => {
e.stopPropagation();
if (isBlocked) return;
if (task.status === 'done') {
onStatusChange?.('todo');
} else {
@@ -129,6 +214,10 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
const handleDragEnd = async (event: any, info: PanInfo) => {
if (info.offset.x > 100) {
if (isBlocked) {
controls.start({ x: 0 });
return;
}
// Swiped right -> Complete
triggerConfetti(0.5, 0.5);
playSuccessSound();
@@ -173,10 +262,17 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
onClick={handleToggleComplete}
data-testid={`checkbox-complete-${task.id}`}
className="mt-0.5"
disabled={isBlocked}
/>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.toggleComplete')}</p>
{isBlocked ? (
<p className="text-destructive font-medium">
Blocked by: {blockingTasks.map(t => t?.title).join(", ")}
</p>
) : (
<p>{t('taskCard.toggleComplete')}</p>
)}
</TooltipContent>
</Tooltip>
@@ -213,6 +309,9 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{t(`status.${task.status}`)}
</Badge>
{/* Shared Status Icon */}
<SharedTaskIcon task={task} />
{task.dueDate && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
@@ -232,6 +331,20 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{task.estimatedDuration}m
</Badge>
)}
{isBlocked && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs border-destructive text-destructive gap-1 animate-pulse">
<Lock className="w-3 h-3" />
Blocked
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Waiting for: {blockingTasks.map(t => t?.title).join(", ")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
@@ -308,21 +421,25 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
{/* Share Option - Check if owner */}
<SharedMenuItem task={task} onShare={() => setIsShareModalOpen(true)} />
<DropdownMenuSeparator />
{task.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
data-testid={`menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (isBlocked) return;
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
disabled={isBlocked}
data-testid={`menu-complete-${task.id}`}
>
{isBlocked ? <Lock className="w-4 h-4 mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
{t('taskCard.markAsDone')}
</DropdownMenuItem>
{task.status === 'todo' && (
<DropdownMenuItem
@@ -482,6 +599,12 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
)}
</ContextMenuContent>
</ContextMenu>
<ShareTaskModal
taskId={task.id}
open={isShareModalOpen}
onOpenChange={setIsShareModalOpen}
/>
</motion.div>
);
}
+181 -87
View File
@@ -7,7 +7,10 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { CalendarIcon, Plus, Tag } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CalendarIcon, Plus, Tag, Check, Link2 } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { parseTaskInput } from '../lib/nlp';
@@ -27,7 +30,10 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
const [energyLevel, setEnergyLevel] = useState<'low' | 'medium' | 'high'>('medium');
const [estimatedDuration, setEstimatedDuration] = useState<number | undefined>();
const [dueDate, setDueDate] = useState<Date | undefined>();
const [labelId, setLabelId] = useState<string | undefined>();
const [dependencies, setDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -37,6 +43,10 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
queryKey: ['/api/labels'],
});
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
const handleSave = () => {
if (!title.trim()) {
setError(t('taskCreation.titleRequired') || 'Title is required');
@@ -51,6 +61,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
estimatedDuration,
dueDate,
labelId,
dependencies,
status: 'todo',
timeTracked: 0,
isTracking: false
@@ -64,7 +75,9 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
@@ -81,14 +94,16 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md mx-4">
<Dialog open={isOpen} onOpenChange={handleClose} >
<DialogContent className="sm:max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-4 h-4" />
@@ -99,7 +114,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
<div className="space-y-4">
<div>
<Input
placeholder={t('taskCreation.titlePlaceholder')}
placeholder={t('smartTask.placeholder')}
value={title}
onChange={(e) => {
setTitle(e.target.value);
@@ -118,10 +133,12 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
autoFocus
/>
<div className="flex items-center gap-1 mt-1">
{title.includes('!') || title.includes('#') || title.toLowerCase().includes('tomorrow') ? (
{title.includes('!') || title.includes('#') ||
title.toLowerCase().includes('tomorrow') || title.toLowerCase().includes('morgen') ||
title.toLowerCase().includes('today') || title.toLowerCase().includes('heute') ? (
<div className="bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 text-xs px-2 py-0.5 rounded-full flex items-center gap-1 animate-pulse">
<Sparkles className="w-3 h-3" />
Smart Input Active
{t('taskCreation.smartInputActive')}
</div>
) : null}
{error && <p className="text-sm text-destructive">{error}</p>}
@@ -139,108 +156,185 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<SelectValue placeholder="Energy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low"> Low Energy</SelectItem>
<SelectItem value="medium"> Medium Energy</SelectItem>
<SelectItem value="high"> High Energy</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.energy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('gamification.energy.low')}</SelectItem>
<SelectItem value="medium">{t('gamification.energy.medium')}</SelectItem>
<SelectItem value="high">{t('gamification.energy.high')}</SelectItem>
</SelectContent>
</Select>
<div className="w-24">
<div className="col-span-1">
<Input
type="number"
placeholder="Min"
placeholder={t('taskCreation.minutesPlaceholder')}
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="text-sm"
/>
</div>
<div className="flex-1">
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<div className="col-span-2">
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
data-testid="button-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Dependencies Selector */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Link2 className="w-4 h-4" />
{t('dependencies.label')}
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2"
data-testid="button-due-date"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<CalendarIcon className="w-4 h-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
<div className="flex flex-wrap gap-1">
{dependencies.length > 0 ? (
dependencies.map((depId) => {
const task = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{task?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setDependencies(dependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.status !== 'done') // Only active tasks
.map((task) => (
<CommandItem
key={task.id}
value={task.title}
onSelect={() => {
if (dependencies.includes(task.id)) {
setDependencies(dependencies.filter((id) => id !== task.id));
} else {
setDependencies([...dependencies, task.id]);
}
// Keep the popover open for multiple selection
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
dependencies.includes(task.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{task.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{task.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
</DialogContent>
</Dialog>
+123 -31
View File
@@ -8,7 +8,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from "@/lib/utils";
import { useQuery } from '@tanstack/react-query';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag, Link2, Check } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useTranslation } from 'react-i18next';
@@ -27,15 +31,15 @@ interface TaskDetailsModalProps {
labels?: Label[];
}
export default function TaskDetailsModal({
isOpen,
onClose,
task,
export default function TaskDetailsModal({
isOpen,
onClose,
task,
onSave,
labels = []
}: TaskDetailsModalProps) {
const { t } = useTranslation();
// Edit form state
const [editedTitle, setEditedTitle] = useState('');
const [editedDescription, setEditedDescription] = useState('');
@@ -43,11 +47,17 @@ export default function TaskDetailsModal({
const [editedPriority, setEditedPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [editedLabelId, setEditedLabelId] = useState<string | null>(null);
const [editedDueDate, setEditedDueDate] = useState('');
const [editedDependencies, setEditedDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
// Notes state
const [notes, setNotes] = useState('');
const [isEditingNotes, setIsEditingNotes] = useState(false);
// Time reporting state
const [isAddingTime, setIsAddingTime] = useState(false);
const [hours, setHours] = useState(0);
@@ -55,7 +65,7 @@ export default function TaskDetailsModal({
const [manualHours, setManualHours] = useState('');
const [timeDescription, setTimeDescription] = useState('');
const [activeTimeTab, setActiveTimeTab] = useState('clock');
// Time entries state - simulate from timeTracked for display
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
@@ -68,7 +78,8 @@ export default function TaskDetailsModal({
setEditedPriority(task.priority as 'low' | 'medium' | 'high');
setEditedLabelId(task.labelId || null);
setEditedDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : '');
setEditedDependencies(task.dependencies || []);
setNotes(task.notes || '');
// Create a single time entry from timeTracked for display purposes
if (task.timeTracked > 0) {
@@ -105,22 +116,23 @@ export default function TaskDetailsModal({
const handleSaveTaskEdit = () => {
if (!task) return;
const updatedTask: Task = {
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: editedStatus,
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null
dueDate: editedDueDate ? new Date(editedDueDate) : null,
dependencies: editedDependencies
};
onSave(updatedTask);
};
const handleSaveNotes = () => {
if (!task) return;
const updatedTask = { ...task, notes };
onSave(updatedTask);
setIsEditingNotes(false);
@@ -130,7 +142,7 @@ export default function TaskDetailsModal({
if (!task) return;
let totalMinutes = 0;
if (activeTimeTab === 'clock') {
totalMinutes = hours * 60 + minutes;
} else {
@@ -149,22 +161,22 @@ export default function TaskDetailsModal({
const updatedTimeEntries = [...timeEntries, newTimeEntry];
const updatedTimeTracked = (task.timeTracked || 0) + totalMinutes;
const updatedTask = {
...task,
const updatedTask = {
...task,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
// Reset time input
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setIsAddingTime(false);
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
};
@@ -173,18 +185,18 @@ export default function TaskDetailsModal({
// Don't allow deleting the main tracked time entry
return;
}
const entryToDelete = timeEntries.find(entry => entry.id === entryId);
if (!entryToDelete) return;
const updatedTimeEntries = timeEntries.filter(entry => entry.id !== entryId);
const updatedTimeTracked = Math.max(0, (task.timeTracked || 0) - entryToDelete.timeSpent);
const updatedTask = {
...task,
const updatedTask = {
...task,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
};
@@ -251,13 +263,13 @@ export default function TaskDetailsModal({
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
{task.title}
</h3>
{task.description && (
<p className="text-sm text-muted-foreground" data-testid="text-task-details-description">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={`text-xs ${getPriorityColor(task.priority)}`}>
{t(`taskDetails.priorityValue.${task.priority}`)}
@@ -384,6 +396,86 @@ export default function TaskDetailsModal({
/>
</div>
{/* Dependencies */}
<div>
<label className="text-sm font-medium flex items-center gap-2 mb-2">
<Link2 className="w-4 h-4" />
Blocked By
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<div className="flex flex-wrap gap-1">
{editedDependencies.length > 0 ? (
editedDependencies.map((depId) => {
const depTask = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{depTask?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setEditedDependencies(editedDependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.id !== task.id && t.status !== 'done') // Cannot depend on self or done tasks (optional logic)
.map((tItem) => (
<CommandItem
key={tItem.id}
value={tItem.title}
onSelect={() => {
if (editedDependencies.includes(tItem.id)) {
setEditedDependencies(editedDependencies.filter((id) => id !== tItem.id));
} else {
setEditedDependencies([...editedDependencies, tItem.id]);
}
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
editedDependencies.includes(tItem.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{tItem.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{tItem.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Save Button */}
<div className="flex gap-2 pt-2">
<Button
@@ -522,7 +614,7 @@ export default function TaskDetailsModal({
{formatTime(hours, minutes)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Hours Picker */}
<div className="space-y-2">
@@ -542,7 +634,7 @@ export default function TaskDetailsModal({
))}
</div>
</div>
{/* Minutes Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">Minutes</label>
@@ -572,7 +664,7 @@ export default function TaskDetailsModal({
{getCurrentTimeDisplay()}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">Hours (decimal)</label>
<Input
+17 -17
View File
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc } from 'lucide-react';
import { Task } from './TaskCard';
import { Task } from '@shared/schema';
import TaskCard from './TaskCard';
interface TaskListProps {
@@ -32,10 +32,10 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
.filter(task => {
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
@@ -53,18 +53,18 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
const priorityOrder: Record<string, number> = { high: 3, medium: 2, low: 1 };
return (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status] - statusOrder[b.status];
const statusOrder: Record<string, number> = { todo: 1, inProgress: 2, done: 3 };
return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0);
default:
return 0;
}
@@ -108,7 +108,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
data-testid="input-search-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
@@ -128,7 +128,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
@@ -154,7 +154,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
{filteredAndSortedTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
{searchQuery || filterBy !== 'all'
{searchQuery || filterBy !== 'all'
? t('taskList.noMatchingTasks')
: t('taskList.noTasks')
}
@@ -165,11 +165,11 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
<TaskCard
key={task.id}
task={task}
onPlay={() => {
onStartTimer={() => {
onTaskUpdate?.(task.id, { isTracking: true, timeTracked: task.timeTracked });
console.log(`Timer started for ${task.title}`);
}}
onPause={() => {
onStopTimer={() => {
onTaskUpdate?.(task.id, { isTracking: false });
console.log(`Timer paused for ${task.title}`);
}}
+5 -6
View File
@@ -214,7 +214,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
};
return (
<div className="space-y-4">
<div className="flex flex-col min-h-[calc(100vh-5rem)] md:min-h-[calc(100vh-3rem)] relative space-y-4">
{/* Header and Search Filters - Sticky */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4">
<div className="space-y-3">
@@ -282,8 +282,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
</div>
{/* Unscheduled Task List */}
<div className="space-y-3">
<div className="space-y-3 flex-1">
{unscheduledTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
@@ -295,7 +294,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
<div className="text-sm font-medium text-muted-foreground mb-2 mt-8 px-1">
{t('taskList.unscheduledTasksLabel')}
</div>
{unscheduledTasks.map((task) => (
@@ -337,8 +336,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
{/* Calendar Section - Sticky at bottom */}
<div className="sticky bottom-0 -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t">
<div className="p-3 max-h-[32vh] overflow-y-auto">
<div className="sticky bottom-0 -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
<div className="p-3 min-h-[200px] max-h-[40vh] overflow-y-auto">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { User } from '@shared/schema';
const profileSchema = z.object({
email: z.string().email("Invalid email address"),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
interface UpdateProfileModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User;
}
export function UpdateProfileModal({ open, onOpenChange, user }: UpdateProfileModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
email: user.email,
},
});
const mutation = useMutation({
mutationFn: async (data: ProfileFormValues) => {
const res = await apiRequest("PATCH", "/api/user/profile", data);
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to update profile");
}
return res.json();
},
onSuccess: (updatedUser) => {
queryClient.setQueryData(['/api/user'], updatedUser);
toast({ title: "Profile updated successfully" });
onOpenChange(false);
},
onError: (error: Error) => {
toast({
title: "Update failed",
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: ProfileFormValues) => {
mutation.mutate(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Profile</DialogTitle>
<DialogDescription>
Update your account information.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Loader2, Bot } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
export function AiSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
// Fetch settings
const { data: settings, isLoading } = useQuery<Record<string, string>>({
queryKey: ['/api/admin/settings'],
});
const [provider, setProvider] = useState("openai");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("gpt-4o");
const [baseUrl, setBaseUrl] = useState("");
useEffect(() => {
if (settings) {
setProvider(settings.ai_provider || "openai");
setApiKey(settings.ai_api_key || "");
setModel(settings.ai_model || "gpt-4o");
setBaseUrl(settings.ai_base_url || "");
}
}, [settings]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const res = await apiRequest("POST", "/api/admin/settings", data);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
toast({ title: "Settings saved" });
},
onError: () => {
toast({ title: "Failed to save settings", variant: "destructive" });
}
});
const handleSave = () => {
mutation.mutate({
ai_provider: provider,
ai_api_key: apiKey,
ai_model: model,
ai_base_url: baseUrl
});
};
if (isLoading) return <Loader2 className="animate-spin" />;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="w-5 h-5" />
{t('settings.ai.title')}
</CardTitle>
<CardDescription>{t('settings.ai.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>{t('settings.ai.provider')}</Label>
<Select value={provider} onValueChange={(v) => {
setProvider(v);
if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o');
if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet');
if (v === 'ollama') {
setBaseUrl('http://localhost:11434');
setModel('llama3');
}
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="google">Google Gemini</SelectItem>
<SelectItem value="ollama">Ollama (Local)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.apiKey')}</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'ollama' ? 'Optional for Ollama' : 'sk-...'}
/>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.model')}</Label>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="e.g. gpt-4, claude-3-opus, llama3"
/>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.baseUrl')}</Label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'}
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
export function SMTPSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
const { data: settings } = useQuery<{
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: boolean;
}>({
queryKey: ["/api/admin/settings"],
});
const [emailSettings, setEmailSettings] = useState({
smtp_host: '',
smtp_port: '',
smtp_user: '',
smtp_pass: '',
smtp_from: '',
smtp_secure: false
});
useEffect(() => {
if (settings) {
setEmailSettings({
smtp_host: settings.smtp_host || '',
smtp_port: settings.smtp_port || '',
smtp_user: settings.smtp_user || '',
smtp_pass: settings.smtp_pass || '',
smtp_from: settings.smtp_from || '',
smtp_secure: settings.smtp_secure || false
});
}
}, [settings]);
const updateSettingsMutation = useMutation({
mutationFn: (data: Partial<typeof settings>) => apiRequest("POST", "/api/admin/settings", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
toast({ title: t('smtp.saveSuccess', "Settings updated") });
},
});
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.smtp.title')}</CardTitle>
<CardDescription>{t('settings.smtp.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('settings.smtp.host')}</Label>
<Input placeholder="smtp.example.com" value={emailSettings.smtp_host} onChange={e => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.port')}</Label>
<Input placeholder="587" value={emailSettings.smtp_port} onChange={e => setEmailSettings({ ...emailSettings, smtp_port: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('settings.smtp.user')}</Label>
<Input placeholder="user@example.com" value={emailSettings.smtp_user} onChange={e => setEmailSettings({ ...emailSettings, smtp_user: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.password')}</Label>
<Input type="password" placeholder="••••••" value={emailSettings.smtp_pass} onChange={e => setEmailSettings({ ...emailSettings, smtp_pass: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.from')}</Label>
<Input placeholder='"TaskFlow" <noreply@taskflow.local>' value={emailSettings.smtp_from} onChange={e => setEmailSettings({ ...emailSettings, smtp_from: e.target.value })} />
</div>
<div className="flex items-center justify-between">
<Label>{t('settings.smtp.secure')}</Label>
<Switch checked={emailSettings.smtp_secure} onCheckedChange={(c) => setEmailSettings({ ...emailSettings, smtp_secure: c })} />
</div>
<Button
onClick={() => updateSettingsMutation.mutate(emailSettings)}
disabled={updateSettingsMutation.isPending}
className="w-full"
>
{updateSettingsMutation.isPending ? t('settings.smtp.saving') : t('settings.smtp.save')}
</Button>
</CardContent>
</Card>
);
}
+107 -49
View File
@@ -1,63 +1,121 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { icons } from "lucide-react";
import { Card } from "@/components/ui/card";
import { useTranslation } from "react-i18next";
import { Reward } from "@shared/schema";
import { Loader2, Coffee, Gamepad2, Palette, Gift, type LucideIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react";
// We don't have types shared for specific API responses yet, so using any for now or defining locally
type Reward = {
id: string;
title: string;
description: string | null;
cost: number;
icon: string;
type: string;
owned?: boolean;
};
interface RewardCardProps {
reward: Reward & { owned?: boolean };
type RewardCardProps = {
reward: Reward;
userXp: number;
onBuy: (rewardId: string) => void;
isBuying?: boolean;
}
userId: string;
};
export function RewardCard({ reward, userXp, onBuy, isBuying }: RewardCardProps) {
const iconMap: Record<string, LucideIcon> = {
"coffee": Coffee,
"gamepad-2": Gamepad2,
"palette": Palette,
"gift": Gift
};
export const RewardCard = ({ reward, userXp, userId }: RewardCardProps) => {
const { t } = useTranslation();
// Dynamic icon
const Icon = (icons as any)[reward.icon] || (icons as any).Gift;
const { toast } = useToast();
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const canAfford = userXp >= reward.cost;
const isOwned = reward.owned;
const IconComponent = iconMap[reward.icon] || Gift; // Fallback
const handleBuy = async () => {
if (!canAfford && !isOwned) return;
setIsLoading(true);
try {
const res = await apiRequest("POST", "/api/rewards/purchase", {
rewardId: reward.id
});
await res.json();
toast({
title: t("rewards.buySuccess"), // Need to add this key or use generic
description: t("rewards.itemPurchased", { item: t(reward.title) }),
});
// Refetch rewards and user (to update XP)
queryClient.invalidateQueries({ queryKey: ["/api/rewards"] });
queryClient.invalidateQueries({ queryKey: ["/api/user"] }); // Assuming user fetch uses this key
queryClient.invalidateQueries({ queryKey: ["/api/users/current"] }); // Or this
} catch (error) {
toast({
title: t("rewards.buyFailed"),
description: t("rewards.insufficientFunds"),
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
return (
<Card className={`flex flex-col h-full ${isOwned ? 'opacity-80' : ''}`}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className={`relative overflow-hidden p-6 transition-colors ${isOwned ? "bg-primary/10 border-primary/20" : "bg-card hover:border-primary/50"
}`}>
<div className="flex flex-col items-center gap-4 text-center">
<div className={`p-4 rounded-full ${isOwned ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
<IconComponent className="h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="font-bold text-lg">{t(reward.title)}</h3>
<p className="text-sm text-muted-foreground">{t(reward.description || "")}</p>
</div>
<div className="mt-2 w-full">
{isOwned ? (
<Button variant="outline" className="w-full cursor-default disabled:opacity-100" disabled>
{t("rewards.owned")}
</Button>
) : (
<Button
className="w-full gap-2"
disabled={!canAfford || isLoading}
onClick={handleBuy}
variant={canAfford ? "default" : "secondary"}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
{t("rewards.buy")}
<span className="font-mono text-xs bg-black/20 px-2 py-0.5 rounded">
{reward.cost} XP
</span>
</>
)}
</Button>
)}
</div>
{isOwned && <Badge variant="secondary">{t('rewards.owned')}</Badge>}
</div>
<CardTitle className="mt-4 text-lg">
{reward.isSystem ? t(reward.title) : reward.title}
</CardTitle>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-muted-foreground">
{reward.isSystem ? t(reward.description) : reward.description}
</p>
</CardContent>
<CardFooter className="flex justify-between items-center border-t pt-4">
<div className="font-bold text-lg">
{reward.cost} <span className="text-xs font-normal text-muted-foreground">XP</span>
</div>
{isOwned ? (
<Button disabled variant="outline" size="sm">
{t('rewards.owned')}
</Button>
) : (
<Button
onClick={() => onBuy(reward.id)}
disabled={!canAfford || isBuying}
size="sm"
variant={canAfford ? "default" : "secondary"}
>
{isBuying ? t('rewards.processing') : t('rewards.buy')}
</Button>
)}
</CardFooter>
</Card>
</Card>
</motion.div>
);
}
};
+1
View File
@@ -21,6 +21,7 @@ const buttonVariants = cva(
secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
// Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
ghost: "border border-transparent",
link: "text-primary underline-offset-4 hover:underline",
},
// Heights are set as "min" heights, because sometimes Ai will place large amount of content
// inside buttons. With a min-height they will look appropriate with small amounts of content,
+235 -27
View File
@@ -11,6 +11,7 @@
"create": "Erstellen",
"kanban": "Kanban",
"achievements": "Erfolge",
"leaderboard": "Bestenliste",
"settings": "Einstellungen"
},
"taskList": {
@@ -69,7 +70,10 @@
"noLabel": "Kein Label",
"dueDate": "Fälligkeitsdatum",
"cancel": "Abbrechen",
"create": "Aufgabe erstellen"
"create": "Aufgabe erstellen",
"smartInputActive": "Smart Input Aktiv",
"energy": "Energie",
"minutesPlaceholder": "Minuten (optional)"
},
"taskDetails": {
"title": "Aufgabendetails",
@@ -174,6 +178,40 @@
"english": "Englisch (English)",
"german": "Deutsch"
},
"account": {
"title": "Benutzerkonto",
"description": "Verwalten Sie Ihre Kontoeinstellungen",
"username": "Benutzername",
"userId": "Benutzer-ID"
},
"social": {
"title": "Soziales & Privatsphäre",
"description": "Verwalten Sie Ihre Sichtbarkeit und soziale Funktionen",
"publicLeaderboard": "Öffentliche Bestenliste",
"publicLeaderboardDesc": "Mein Profil auf der globalen Bestenliste anzeigen",
"searchable": "Auffindbarkeit erlauben",
"searchableDesc": "Anderen Benutzern erlauben, mich zum Teilen von Aufgaben zu finden",
"shareAccess": "Zugriff auf alle Aufgaben teilen..."
},
"admin": {
"title": "Administration",
"description": "Systemweite Einstellungen und Benutzerverwaltung",
"manageUsers": "Benutzer & Registrierung verwalten",
"smtpSettings": "SMTP Einstellungen"
},
"smtp": {
"title": "E-Mail Einstellungen (SMTP)",
"description": "Postausgangsserver konfigurieren",
"host": "Host",
"port": "Port",
"user": "Benutzer",
"password": "Passwort",
"from": "Absenderadresse",
"secure": "Sicher (TLS)",
"save": "E-Mail Einstellungen speichern",
"saving": "Speichere...",
"saveSuccess": "Einstellungen erfolgreich gespeichert"
},
"labels": {
"title": "Aufgabenlabels",
"description": "Erstellen und verwalten Sie Labels, um Ihre Aufgaben zu organisieren",
@@ -193,17 +231,67 @@
"updated": "Label aktualisiert",
"updatedDescription": "Ihr Label wurde erfolgreich aktualisiert.",
"deleted": "Label gelöscht",
"deletedDescription": "Ihr Label wurde erfolgreich gelöscht."
"deletedDescription": "Ihr Label wurde erfolgreich gelöscht.",
"share": "Label teilen"
},
"templates": {
"title": "Projektvorlagen",
"description": "Verwende Vorlagen zum schnellen Erstellen von Projekten",
"manageTemplates": "Vorlagen verwalten"
"manageTemplates": "Vorlagen verwalten",
"copy": "In die Zwischenablage kopieren",
"instructions": "Konfigurieren Sie Ihren MCP-Client (z. B. Claude Desktop) mit dieser URL und diesem Token."
},
"ai": {
"title": "KI-Konfiguration",
"description": "Konfigurieren Sie den globalen KI-Anbieter für den Assistenten.",
"provider": "KI-Anbieter",
"apiKey": "API-Schlüssel",
"model": "Modellname",
"baseUrl": "Basis-URL (Optional, z. B. für Ollama)",
"enableUser": "KI-Assistent aktivieren",
"enableUserDesc": "Zeige das KI-Chat-Widget an."
}
},
"ai": {
"title": "KI-Assistent",
"welcome": "Wie kann ich Ihnen heute bei Ihren Aufgaben helfen?",
"thinking": "Denke nach...",
"placeholder": "Stellen Sie eine Frage...",
"error": "Ich bin auf einen Fehler gestoßen"
},
"userManagement": {
"title": "Benutzerverwaltung",
"registration": "Registrierung",
"registrationDesc": "Zugriff auf die Plattform verwalten",
"publicRegistration": "Öffentliche Registrierung",
"publicRegistrationDesc": "Neuen Benutzern die Registrierung erlauben",
"registeredUsers": "Registrierte Benutzer",
"registeredUsersDesc": "Benutzerkonten und Rollen verwalten",
"createUser": "Benutzer erstellen",
"roles": {
"admin": "Administrator",
"user": "Benutzer"
},
"table": {
"user": "Benutzer",
"role": "Rolle",
"status": "Status",
"xp": "EP / Level",
"actions": "Aktionen",
"activate": "Aktivieren",
"deactivate": "Deaktivieren",
"active": "Aktiv",
"inactive": "Inaktiv"
},
"settingsUpdated": "Einstellungen erfolgreich aktualisiert",
"userDeleted": "Benutzer erfolgreich gelöscht",
"deleteUser": "Benutzer löschen",
"deleteConfirm": "Sind Sie sicher, dass Sie den Benutzer '{{username}}' löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"typeToConfirm": "Geben Sie den Benutzernamen ein zur Bestätigung"
},
"projectTemplate": {
"title": "Projektvorlagen",
"description": "Wähle eine Vorlage, um ein neues Projekt mit vorkonfigurierten Aufgaben zu erstellen",
"pageDescription": "Wähle eine Vorlage, um ein neues Projekt mit vorkonfigurierten Aufgaben zu erstellen",
"websiteRedesign": "Website-Redesign",
"websiteDescription": "Umfassendes Website-Redesign-Projekt mit Design-, Entwicklungs- und Testphasen",
"mobileApp": "Mobile App-Launch",
@@ -330,7 +418,13 @@
"xp": "{{count}} EP",
"nextLevel": "{{count}} EP",
"currentXP": "{{current}} / {{next}} EP",
"viewDetails": "Details anzeigen"
"viewDetails": "Details anzeigen",
"source": {
"task_completion": "Aufgabe erledigt",
"daily_streak": "Täglicher Serien-Bonus",
"daily_clear_bonus": "Tagesziel-Bonus",
"goal_completed": "Ziel erreicht"
}
},
"ranks": {
"novice": "Einsteiger",
@@ -339,9 +433,45 @@
"architect": "Architekt",
"master": "Meister"
},
"leaderboardPage": {
"title": "Bestenliste",
"subtitle": "Top-Performer in der Community",
"globalRankings": "Globale Rangliste",
"globalRankingsDesc": "Benutzer sortiert nach Gesamt-EP (nur Opt-in)",
"noUsers": "Noch keine Benutzer auf der Bestenliste. Sei der Erste und tritt in den Einstellungen bei!",
"xp": "EP"
},
"share": {
"taskTitle": "Aufgabe teilen",
"allTasksTitle": "Alle Aufgaben teilen",
"allTasksDesc": "Gewähre einem Benutzer Lesezugriff auf ALLE deine Aufgaben.",
"searchPlaceholder": "Benutzer nach Namen suchen...",
"noUsers": "Keine Benutzer gefunden.",
"typeToSearch": "Tippe mindestens 2 Zeichen zum Suchen.",
"grantAccess": "Zugriff gewähren",
"successTask": "Aufgabe erfolgreich geteilt",
"errorTask": "Fehler beim Teilen der Aufgabe",
"successAccess": "Zugriff erfolgreich gewährt",
"successAccessDesc": "Benutzer kann nun alle deine Aufgaben sehen.",
"errorAccess": "Fehler beim Gewähren des Zugriffs",
"sharedWith": "Geteilt mit",
"unshare": "Entfernen",
"confirmShareTitle": "Aufgabe teilen",
"confirmShareDesc": "Möchtest du diese Aufgabe wirklich mit {{username}} teilen?",
"confirm": "Teilen bestätigen",
"cancel": "Abbrechen",
"removeSuccess": "Zugriff erfolgreich entfernt"
},
"achievements": {
"title": "Erfolge",
"subtitle": "Verfolge deinen Fortschritt und deine Ziele",
"rewardsDescription": "Gib deine {{xp}} EP für exklusive Belohnungen aus!",
"addReward": "Belohnung hinzufügen",
"createCustomReward": "Eigene Belohnung erstellen",
"rewardTitle": "Titel",
"rewardDescription": "Beschreibung",
"rewardCost": "Kosten (EP)",
"createRewardBtn": "Belohnung erstellen",
"currentStreak": "Aktuelle Serie",
"days": "{{count}} Tage",
"bestStreak": "Rekord: {{count}} Tage",
@@ -358,6 +488,14 @@
"weekly": "Wöchentlich",
"monthly": "Monatlich",
"yearly": "Jährlich",
"inventory": "Inventar",
"history": "Verlauf",
"fromLastWeek": "seit letzter Woche",
"noInventory": "Du hast noch keine Belohnungen gekauft.",
"goToShop": "Zum Prämienshop",
"purchasedAt": "Gekauft am {{date}}",
"xpHistory": "EP Verlauf",
"noHistory": "Noch keine EP-Aktivität",
"types": {
"weekly_tasks": "Wochenaufgaben",
"total_xp": "Gesamt EP",
@@ -366,8 +504,33 @@
"levelDetails": "Level Details",
"nextReward": "Nächste Belohnung",
"unlockReward": "Erweiterte Analysen auf Level {{level}} freischalten",
"goalTitlePlaceholder": "z.B. 50 Aufgaben erledigen",
"fromLastWeek": "seit letzter Woche"
"goalTitlePlaceholder": "z.B. 50 Aufgaben erledigen"
},
"rewards": {
"shopTitle": "Prämienshop",
"title": "Prämienshop",
"subtitle": "Tausche deine EP gegen Belohnungen",
"buy": "Kaufen",
"owned": "Im Besitz",
"insufficientFunds": "Nicht genug EP",
"buySuccess": "Kauf erfolgreich",
"itemPurchased": "Du hast {{item}} gekauft",
"buyFailed": "Kauf fehlgeschlagen",
"cost": "{{cost}} EP",
"defaults": {
"coffee": {
"title": "Kaffeepause",
"description": "Genieße 15 Min Pause ohne schlechtes Gewissen"
},
"gaming": {
"title": "Gaming Session",
"description": "1 Stunde Videospiele"
},
"theme": {
"title": "Dunkles Design",
"description": "Schalte das exklusive Dark Theme frei"
}
}
},
"analytics": {
"mon": "Mo",
@@ -400,25 +563,70 @@
"medium": "⚡⚡ Mittlere Energie",
"high": "⚡⚡⚡ Viel Energie"
},
"rewards": {
"shopTitle": "Belohnungen",
"buy": "Kaufen",
"insufficientFunds": "Nicht genug EP",
"owned": "Im Besitz",
"processing": "Verarbeite...",
"defaults": {
"coffee": {
"title": "Kaffeepause",
"description": "Mach eine 15 Min Pause"
},
"gaming": {
"title": "Gaming Session",
"description": "1 Stunde zocken ohne schlechtes Gewissen"
},
"theme": {
"title": "Goldenes Design",
"description": "Schalte das goldene Design frei"
}
}
"validation": {
"required": "Erforderlich",
"email": "Ungültige E-Mail-Adresse",
"minLength": "Muss mindestens {{min}} Zeichen lang sein",
"passwordMatch": "Passwörter stimmen nicht überein"
},
"smartTask": {
"placeholder": "Tippe eine Aufgabe wie 'Milch kaufen morgen'..."
},
"dependencies": {
"label": "Blockiert durch",
"selectPlaceholder": "Blockierende Aufgaben wählen...",
"blocked": "Blockiert",
"blockedBy": "Wartet auf"
},
"auth": {
"rememberMe": "Angemeldet bleiben",
"forgotPassword": "Passwort vergessen?",
"email": "E-Mail",
"forgotPasswordTitle": "Passwort vergessen",
"forgotPasswordDesc": "Geben Sie Ihre E-Mail ein, um Ihr Passwort zurückzusetzen",
"resetPasswordTitle": "Passwort zurücksetzen",
"resetPasswordDesc": "Geben Sie unten Ihr neues Passwort ein",
"sendResetLink": "Link senden",
"backToLogin": "Zurück zum Login",
"resetSuccess": "Passwort erfolgreich zurückgesetzt",
"resetEmailSent": "Reset-E-Mail gesendet",
"resetEmailSentDesc": "Wenn ein Konto mit dieser E-Mail existiert, haben wir Ihnen Anweisungen gesendet.",
"checkSpam": "Bitte prüfen Sie Ihren Spam-Ordner.",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"resetBtn": "Passwort zurücksetzen",
"password": "Passwort",
"resetting": "Wird zurückgesetzt...",
"sending": "Wird gesendet...",
"invalidToken": "Ungültiger Link",
"missingToken": "Reset-Token fehlt.",
"redirecting": "Weiterleitung zum Login...",
"changePassword": "Passwort ändern",
"changePasswordDesc": "Geben Sie Ihr aktuelles Passwort und ein neues Passwort ein.",
"currentPassword": "Aktuelles Passwort",
"changing": "Ändert...",
"passwordChangedSuccess": "Passwort erfolgreich geändert",
"changePasswordFailed": "Passwortänderung fehlgeschlagen",
"incorrectCurrentPassword": "Falsches aktuelles Passwort",
"currentPasswordRequired": "Aktuelles Passwort ist erforderlich",
"passwordMinLength": "Passwort muss mindestens 6 Zeichen lang sein",
"welcomeBack": "Willkommen zurück",
"signInDesc": "Melden Sie sich an oder erstellen Sie ein neues Konto, um zu beginnen",
"signInDescNoReg": "Melden Sie sich an, um zu beginnen",
"login": "Anmelden",
"register": "Registrieren",
"usernameOrEmail": "Benutzername oder E-Mail",
"username": "Benutzername",
"enterUsernameOrEmail": "Benutzername oder E-Mail eingeben",
"chooseUsername": "Benutzername wählen",
"enterEmail": "E-Mail eingeben",
"enterPassword": "Passwort eingeben",
"loggingIn": "Anmelden...",
"creatingAccount": "Konto wird erstellt...",
"signIn": "Anmelden",
"createAccount": "Konto erstellen",
"registrationDisabled": "Registrierung ist derzeit deaktiviert.",
"heroTitle": "TaskFlow",
"heroSubtitle": "Meistern Sie Ihre Produktivität mit KI-gesteuertem Aufgabenmanagement, gamifizierten Erfolgen und intelligenten Fokusmodi."
}
}
+260 -38
View File
@@ -11,6 +11,7 @@
"create": "Create",
"kanban": "Kanban",
"achievements": "Achievements",
"leaderboard": "Leaderboard",
"settings": "Settings"
},
"taskList": {
@@ -64,12 +65,15 @@
"title": "New Task",
"titlePlaceholder": "What needs to be done?",
"descriptionPlaceholder": "Add a description (optional)",
"dueDate": "Due Date",
"priority": "Priority",
"label": "Label",
"noLabel": "No Label",
"dueDate": "Due date",
"create": "Create Task",
"cancel": "Cancel",
"create": "Create Task"
"smartInputActive": "Smart Input Active",
"energy": "Energy",
"minutesPlaceholder": "Minutes (optional)"
},
"taskDetails": {
"title": "Task Details",
@@ -174,6 +178,40 @@
"english": "English",
"german": "German (Deutsch)"
},
"account": {
"title": "Account",
"description": "Manage your account settings",
"username": "Username",
"userId": "User ID"
},
"social": {
"title": "Social & Privacy",
"description": "Manage your visibility and social features",
"publicLeaderboard": "Public Leaderboard",
"publicLeaderboardDesc": "Show my profile on the global leaderboard",
"searchable": "Allow others to find me",
"searchableDesc": "Allow users to search for me to share tasks",
"shareAccess": "Share access to all tasks..."
},
"admin": {
"title": "Admin Settings",
"description": "System administration and configuration",
"manageUsers": "Manage Users",
"smtpSettings": "SMTP / Email Settings"
},
"smtp": {
"title": "Email Settings (SMTP)",
"description": "Configure outgoing email server",
"host": "Host",
"port": "Port",
"user": "User",
"password": "Password",
"from": "From Address",
"secure": "Secure (TLS)",
"save": "Save Email Settings",
"saving": "Saving...",
"saveSuccess": "Settings saved successfully"
},
"labels": {
"title": "Task Labels",
"description": "Create and manage labels to organize your tasks",
@@ -193,17 +231,80 @@
"updated": "Label updated",
"updatedDescription": "Your label has been updated successfully.",
"deleted": "Label deleted",
"deletedDescription": "Your label has been deleted successfully."
"deletedDescription": "Your label has been deleted successfully.",
"share": "Share Label"
},
"templates": {
"title": "Project Templates",
"description": "Use templates to quickly create projects",
"manageTemplates": "Manage Templates"
},
"mcp": {
"title": "MCP Server Integration",
"description": "Connect AI assistants to TaskFlow via Model Context Protocol.",
"status": "Server Status",
"running": "Active",
"url": "Endpoint URL (SSE)",
"apiKey": "Access Token",
"generate": "Generate Token",
"revoke": "Revoke Token",
"generated": "Access Token generated",
"revoked": "Access Token revoked",
"noKey": "No token active. Generate one to connect.",
"copy": "Copy to Clipboard",
"instructions": "Configure your MCP client (e.g. Claude Desktop) with this URL and Token."
},
"ai": {
"title": "AI Configuration",
"description": "Configure the global AI provider for the assistant.",
"provider": "AI Provider",
"apiKey": "API Key",
"model": "Model Name",
"baseUrl": "Base URL (Optional, e.g. for Ollama)",
"enableUser": "Enable AI Assistant",
"enableUserDesc": "Show the AI chat widget."
}
},
"ai": {
"title": "AI Assistant",
"welcome": "How can I help you manage your tasks today?",
"thinking": "Thinking...",
"placeholder": "Ask a question...",
"error": "I encountered an error"
},
"userManagement": {
"title": "User Management",
"registration": "Registration",
"registrationDesc": "Control access to the platform",
"publicRegistration": "Public Registration",
"publicRegistrationDesc": "Allow new users to sign up",
"registeredUsers": "Registered Users",
"registeredUsersDesc": "Manage user accounts and roles",
"createUser": "Create User",
"roles": {
"admin": "Administrator",
"user": "User"
},
"table": {
"user": "User",
"role": "Role",
"status": "Status",
"xp": "XP / Level",
"actions": "Actions",
"activate": "Activate",
"deactivate": "Deactivate",
"active": "Active",
"inactive": "Inactive"
},
"settingsUpdated": "Settings updated successfully",
"userDeleted": "User deleted successfully",
"deleteUser": "Delete User",
"deleteConfirm": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
"typeToConfirm": "Type username to confirm"
},
"projectTemplate": {
"title": "Project Templates",
"description": "Select a template to create a new project with pre-configured tasks",
"pageDescription": "Select a template to create a new project with pre-configured tasks",
"websiteRedesign": "Website Redesign",
"websiteDescription": "Comprehensive website redesign project with design, development, and testing phases",
"mobileApp": "Mobile App Launch",
@@ -324,14 +425,6 @@
"empty": "No active tasks. Enjoy your day!"
}
},
"gamification": {
"level": "Level {{level}}",
"streak": "{{count}}",
"xp": "{{count}} XP",
"nextLevel": "{{count}} XP",
"currentXP": "{{current}} / {{next}} XP",
"viewDetails": "View Details"
},
"ranks": {
"novice": "Novice",
"builder": "Builder",
@@ -339,9 +432,45 @@
"architect": "Architect",
"master": "Master"
},
"leaderboardPage": {
"title": "Leaderboard",
"subtitle": "Top performers in the community",
"globalRankings": "Global Rankings",
"globalRankingsDesc": "Users ranked by total XP (opt-in only)",
"noUsers": "No users on the leaderboard yet. Be the first to join in Settings!",
"xp": "XP"
},
"share": {
"taskTitle": "Share Task",
"allTasksTitle": "Share All Tasks",
"allTasksDesc": "Grant a user read-only access to ALL your tasks.",
"searchPlaceholder": "Search users by username...",
"noUsers": "No users found.",
"typeToSearch": "Type at least 2 characters to search.",
"grantAccess": "Grant Access",
"successTask": "Task shared successfully",
"errorTask": "Failed to share task",
"successAccess": "Access granted successfully",
"successAccessDesc": "User can now view all your tasks.",
"errorAccess": "Failed to grant access",
"sharedWith": "Shared with",
"unshare": "Unshare",
"confirmShareTitle": "Share Task",
"confirmShareDesc": "Are you sure you want to share this task with {{username}}?",
"confirm": "Confirm Share",
"cancel": "Cancel",
"removeSuccess": "Access removed successfully"
},
"achievements": {
"title": "Achievements",
"subtitle": "Track your progress and goals",
"rewardsDescription": "Spend your {{xp}} XP on exclusive rewards!",
"addReward": "Add Reward",
"createCustomReward": "Create Custom Reward",
"rewardTitle": "Title",
"rewardDescription": "Description",
"rewardCost": "Cost (XP)",
"createRewardBtn": "Create Reward",
"currentStreak": "Current Streak",
"days": "{{count}} Days",
"bestStreak": "Best: {{count}} Days",
@@ -358,16 +487,64 @@
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly",
"inventory": "Inventory",
"history": "History",
"fromLastWeek": "from last week",
"noInventory": "You have not purchased any rewards yet.",
"goToShop": "Go to Reward Shop",
"purchasedAt": "Purchased on {{date}}",
"xpHistory": "XP History",
"noHistory": "No history yet",
"types": {
"weekly_tasks": "Weekly Tasks",
"total_xp": "Total XP",
"streak": "Streak Days"
"streak": "Daily Streak"
}
},
"gamification": {
"energy": {
"low": "⚡ Low Energy",
"medium": "⚡⚡ Medium Energy",
"high": "⚡⚡⚡ High Energy"
},
"levelDetails": "Level Details",
"nextReward": "Next Level Reward",
"unlockReward": "Unlock advanced analytics at Level {{level}}",
"goalTitlePlaceholder": "e.g., Complete 50 Tasks",
"fromLastWeek": "from last week"
"level": "Level {{level}}",
"streak": "{{count}}",
"xp": "{{count}} XP",
"nextLevel": "{{count}} XP",
"currentXP": "{{current}} / {{next}} XP",
"viewDetails": "View Details",
"source": {
"task_completion": "Task Completed",
"daily_streak": "Daily Streak Bonus",
"daily_clear_bonus": "Daily Clear Bonus",
"goal_completed": "Goal Completed"
}
},
"rewards": {
"shopTitle": "Reward Shop",
"title": "Reward Shop",
"subtitle": "Spend your XP on rewards",
"buy": "Buy",
"owned": "Owned",
"insufficientFunds": "Not enough XP",
"buySuccess": "Purchase Successful",
"itemPurchased": "You bought {{item}}",
"buyFailed": "Purchase Failed",
"cost": "{{cost}} XP",
"defaults": {
"coffee": {
"title": "Coffee Break",
"description": "Enjoy a guilt-free 15min break"
},
"gaming": {
"title": "Gaming Session",
"description": "1 hour of video games"
},
"theme": {
"title": "Dark Theme",
"description": "Unlock the exclusive dark theme"
}
}
},
"analytics": {
"mon": "Mon",
@@ -400,25 +577,70 @@
"medium": "⚡⚡ Medium Energy",
"high": "⚡⚡⚡ High Energy"
},
"rewards": {
"shopTitle": "Reward Shop",
"buy": "Buy",
"insufficientFunds": "Not enough XP",
"owned": "Owned",
"processing": "Processing...",
"defaults": {
"coffee": {
"title": "Coffee Break",
"description": "Take a 15 min coffee break"
},
"gaming": {
"title": "Gaming Session",
"description": "1 hour of guilt-free gaming"
},
"theme": {
"title": "Golden Theme",
"description": "Unlock the golden theme"
}
}
"validation": {
"required": "Required",
"email": "Invalid email address",
"minLength": "Must be at least {{min}} characters",
"passwordMatch": "Passwords do not match"
},
"smartTask": {
"placeholder": "Type a task like 'Buy milk tomorrow'..."
},
"dependencies": {
"label": "Blocked By",
"selectPlaceholder": "Select blocking tasks...",
"blocked": "Blocked",
"blockedBy": "Waiting for"
},
"auth": {
"rememberMe": "Remember me",
"forgotPassword": "Forgot password?",
"email": "Email",
"forgotPasswordTitle": "Forgot Password",
"forgotPasswordDesc": "Enter your email to reset your password",
"resetPasswordTitle": "Reset Password",
"resetPasswordDesc": "Enter your new password below",
"sendResetLink": "Send Reset Link",
"backToLogin": "Back to Login",
"resetSuccess": "Password reset successful",
"resetEmailSent": "Reset email sent",
"resetEmailSentDesc": "If an account exists with that email, we've sent you instructions to reset your password.",
"checkSpam": "Please check your spam folder if you don't see it.",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"resetBtn": "Reset Password",
"password": "Password",
"resetting": "Resetting...",
"sending": "Sending...",
"invalidToken": "Invalid Link",
"missingToken": "Missing reset token.",
"redirecting": "Redirecting to login...",
"changePassword": "Change Password",
"changePasswordDesc": "Enter your current password and a new password.",
"currentPassword": "Current Password",
"changing": "Changing...",
"passwordChangedSuccess": "Password changed successfully",
"changePasswordFailed": "Change password failed",
"incorrectCurrentPassword": "Incorrect current password",
"currentPasswordRequired": "Current password is required",
"passwordMinLength": "Password must be at least 6 characters",
"welcomeBack": "Welcome Back",
"signInDesc": "Sign in to your account or create a new one to get started",
"signInDescNoReg": "Sign in to your account to get started",
"login": "Login",
"register": "Register",
"usernameOrEmail": "Username or Email",
"username": "Username",
"enterUsernameOrEmail": "Enter username or email",
"chooseUsername": "Choose a username",
"enterEmail": "Enter your email",
"enterPassword": "Enter your password",
"loggingIn": "Logging in...",
"creatingAccount": "Creating account...",
"signIn": "Sign In",
"createAccount": "Create Account",
"registrationDisabled": "Registration is currently disabled.",
"heroTitle": "TaskFlow",
"heroSubtitle": "Master your productivity with AI-driven task management, gamified achievements, and intelligent focus modes."
}
}
+10 -6
View File
@@ -31,17 +31,21 @@ export const parseTaskInput = (input: string): ParsedTask => {
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (title.match(/\btomorrow\b/i)) {
const matchTomorrow = title.match(/\b(tomorrow|morgen)\b/i);
const matchToday = title.match(/\b(today|heute)\b/i);
const matchNextWeek = title.match(/\b(next week|nächste woche)\b/i);
if (matchTomorrow) {
dueDate = tomorrow;
title = title.replace(/\btomorrow\b/i, '').trim();
} else if (title.match(/\btoday\b/i)) {
title = title.replace(matchTomorrow[0], '').trim();
} else if (matchToday) {
dueDate = today;
title = title.replace(/\btoday\b/i, '').trim();
} else if (title.match(/\bnext week\b/i)) {
title = title.replace(matchToday[0], '').trim();
} else if (matchNextWeek) {
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
dueDate = nextWeek;
title = title.replace(/\bnext week\b/i, '').trim();
title = title.replace(matchNextWeek[0], '').trim();
}
return {
+102 -47
View File
@@ -8,7 +8,7 @@ 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 { useState, useCallback } 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';
@@ -73,6 +73,7 @@ export default function AchievementsPage({ user }: { user: User }) {
}
});
// Rewards
const { data: rewards = [] } = useQuery<Reward[]>({
queryKey: ['/api/rewards', user.id],
@@ -82,40 +83,13 @@ export default function AchievementsPage({ user }: { user: User }) {
}
});
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 { data: history = [] } = useQuery<any[]>({
queryKey: ['/api/user/history'],
});
const handleBuyReward = (rewardId: string) => {
buyRewardMutation.mutate(rewardId);
};
const { data: inventory = [] } = useQuery<any[]>({
queryKey: ['/api/user/inventory'],
});
const handleCreateGoal = (e: React.FormEvent) => {
e.preventDefault();
@@ -166,10 +140,10 @@ export default function AchievementsPage({ user }: { user: User }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
toast({ title: "Reward created!" });
toast({ title: t('rewards.created', 'Reward created!') });
},
onError: () => {
toast({ title: "Failed to create reward", variant: "destructive" });
toast({ title: t('rewards.createError', 'Failed to create reward'), variant: "destructive" });
}
});
@@ -183,6 +157,8 @@ export default function AchievementsPage({ user }: { user: User }) {
});
};
const [activeTab, setActiveTab] = useState("overview");
return (
<div className="space-y-6 pb-20 md:pb-0">
<div className="flex items-center justify-between">
@@ -192,10 +168,12 @@ export default function AchievementsPage({ user }: { user: User }) {
</div>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview">{t('achievements.title')}</TabsTrigger>
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
<TabsTrigger value="inventory">{t('achievements.inventory')}</TabsTrigger>
<TabsTrigger value="history">{t('achievements.history')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
@@ -417,33 +395,33 @@ export default function AchievementsPage({ user }: { user: User }) {
<Trophy className="h-5 w-5 text-primary" />
<CardTitle>{t('rewards.shopTitle')}</CardTitle>
</div>
<CardDescription>Spend your {user.xp} XP on exclusive rewards!</CardDescription>
<CardDescription>{t('achievements.rewardsDescription', { xp: user.xp })}</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Reward
{t('achievements.addReward')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Custom Reward</DialogTitle>
<DialogTitle>{t('achievements.createCustomReward')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateReward} className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<label className="text-sm font-medium">{t('achievements.rewardTitle')}</label>
<Input name="title" required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<label className="text-sm font-medium">{t('achievements.rewardDescription')}</label>
<Input name="description" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Cost (XP)</label>
<label className="text-sm font-medium">{t('achievements.rewardCost')}</label>
<Input name="cost" type="number" required />
</div>
<Button type="submit" className="w-full">Create Reward</Button>
<Button type="submit" className="w-full">{t('achievements.createRewardBtn')}</Button>
</form>
</DialogContent>
</Dialog>
@@ -455,15 +433,92 @@ export default function AchievementsPage({ user }: { user: User }) {
<RewardCard
reward={reward}
userXp={user.xp}
onBuy={handleBuyReward}
isBuying={buyRewardMutation.isPending}
userId={user.id}
/>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
<TabsContent value="inventory">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{inventory.length === 0 ? (
<div className="col-span-full text-center py-12 text-muted-foreground">
<Trophy className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>{t('achievements.noInventory')}</p>
<Button variant="link" onClick={() => document.querySelector('[value="rewards"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))}>
{t('achievements.goToShop')}
</Button>
</div>
) : (
inventory.map((item) => (
<Card key={item.id} className="overflow-hidden">
<div className="h-32 bg-muted flex items-center justify-center text-4xl">
{/* Quick icon mapping or default */}
{item.reward.icon === 'coffee' ? '☕' :
item.reward.icon === 'gamepad-2' ? '🎮' :
item.reward.icon === 'palette' ? '🎨' : '🎁'}
</div>
<CardHeader className="pb-2">
<CardTitle className="text-base">{item.reward.title.startsWith('rewards.') ? t(item.reward.title) : item.reward.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-2">
{item.reward.description?.startsWith('rewards.') ? t(item.reward.description) : item.reward.description}
</p>
<p className="text-xs text-muted-foreground">
{t('achievements.purchasedAt', { date: new Date(item.purchasedAt).toLocaleDateString() })}
</p>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>{t('achievements.xpHistory')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{history.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">{t('achievements.noHistory')}</p>
) : (
history.map((event) => (
<div key={event.id} className="flex items-center justify-between py-3 border-b last:border-0 hover:bg-muted/50 px-2 rounded-md transition-colors">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${event.source === 'task_completion' ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
event.source === 'daily_streak' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' :
'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
}`}>
{event.source === 'task_completion' ? <CheckCircle2 className="h-4 w-4" /> :
event.source === 'daily_streak' ? <Flame className="h-4 w-4" /> :
<Trophy className="h-4 w-4" />}
</div>
<div>
<p className="font-medium text-sm">
{t(`gamification.source.${event.source}`, { defaultValue: event.source }) as string}
</p>
<p className="text-xs text-muted-foreground">
{new Date(event.createdAt).toLocaleString()}
</p>
</div>
</div>
<span className="font-bold text-green-600 dark:text-green-400">
+{event.amount} XP
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs >
</div >
);
}
+31
View File
@@ -0,0 +1,31 @@
import { SMTPSettingsCard } from "@/components/admin/SMTPSettingsCard";
import { AiSettingsCard } from "@/components/admin/AiSettingsCard";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { useLocation } from "wouter";
export default function AdminSettings() {
const { t } = useTranslation();
const [, setLocation] = useLocation();
return (
<div className="space-y-6 container mx-auto p-4 max-w-5xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => setLocation("/settings")}>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('settings.admin.title')}</h1>
<p className="text-muted-foreground">{t('settings.admin.description')}</p>
</div>
</div>
<div className="grid gap-6">
<AiSettingsCard />
<SMTPSettingsCard />
</div>
</div>
);
}
+104 -47
View File
@@ -12,15 +12,17 @@ import {
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { useTranslation } from "react-i18next";
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 { Plus, Shield, 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 { t } = useTranslation();
const { toast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newUser, setNewUser] = useState({ username: '', email: '', password: '', role: 'user' });
@@ -34,6 +36,10 @@ export default function AdminUserManagement() {
queryKey: ["/api/admin/settings"],
});
// Delete User State
const [deleteUser, setDeleteUser] = useState<User | null>(null);
const [confirmText, setConfirmText] = useState("");
// Mutations
const toggleActiveMutation = useMutation({
mutationFn: (userId: string) => apiRequest("POST", `/api/admin/users/${userId}/toggle-active`),
@@ -44,14 +50,25 @@ export default function AdminUserManagement() {
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 }),
const updateSettingsMutation = useMutation({
mutationFn: (data: Partial<typeof settings>) => apiRequest("POST", "/api/admin/settings", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
toast({ title: "Settings updated" });
toast({ title: t('userManagement.settingsUpdated', 'Settings updated') });
},
});
const deleteUserMutation = useMutation({
mutationFn: (userId: string) => apiRequest("DELETE", `/api/admin/users/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
setDeleteUser(null);
setConfirmText("");
toast({ title: t('userManagement.userDeleted', 'User deleted') });
},
onError: (e: Error) => toast({ title: "Failed to delete", description: e.message, variant: "destructive" }),
});
const createUserMutation = useMutation({
mutationFn: (data: typeof newUser) => apiRequest("POST", "/api/admin/users", data),
onSuccess: () => {
@@ -66,64 +83,66 @@ export default function AdminUserManagement() {
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>
<h1 className="text-3xl font-bold tracking-tight">{t('userManagement.title')}</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>
{/* Global Settings (Registration Only now) */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t('userManagement.registration')}</CardTitle>
<CardDescription>{t('userManagement.registrationDesc')}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="space-y-1">
<p className="font-medium">{t('userManagement.publicRegistration')}</p>
<p className="text-sm text-muted-foreground">{t('userManagement.publicRegistrationDesc')}</p>
</div>
<Switch
checked={settings?.registration_enabled}
onCheckedChange={(checked) => updateSettingsMutation.mutate({ registration_enabled: checked })}
/>
</CardContent>
</Card>
</div>
{/* User Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Registered Users</CardTitle>
<CardDescription>Manage user accounts and roles</CardDescription>
<CardTitle>{t('userManagement.registeredUsers')}</CardTitle>
<CardDescription>{t('userManagement.registeredUsersDesc')}</CardDescription>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create User</Button>
<Button><Plus className="mr-2 h-4 w-4" /> {t('userManagement.createUser')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
<DialogTitle>{t('userManagement.createUser')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Username</Label>
<Label>{t('settings.account.username')}</Label>
<Input value={newUser.username} onChange={e => setNewUser({ ...newUser, username: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Label>{t('auth.email')}</Label>
<Input value={newUser.email} onChange={e => setNewUser({ ...newUser, email: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Password</Label>
<Label>{t('auth.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>
<Label>{t('userManagement.table.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>
<option value="user">{t('userManagement.roles.user')}</option>
<option value="admin">{t('userManagement.roles.admin')}</option>
</select>
</div>
<Button
@@ -131,7 +150,7 @@ export default function AdminUserManagement() {
disabled={createUserMutation.isPending}
className="w-full"
>
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
{createUserMutation.isPending ? t('common.loading') : t('userManagement.createUser')}
</Button>
</div>
</DialogContent>
@@ -141,17 +160,17 @@ export default function AdminUserManagement() {
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>XP / Level</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t('userManagement.table.user')}</TableHead>
<TableHead>{t('userManagement.table.role')}</TableHead>
<TableHead>{t('userManagement.table.status')}</TableHead>
<TableHead>{t('userManagement.table.xp')}</TableHead>
<TableHead className="text-right">{t('userManagement.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">Loading users...</TableCell>
<TableCell colSpan={5} className="text-center h-24">{t('common.loading')}</TableCell>
</TableRow>
) : users.map((user) => (
<TableRow key={user.id}>
@@ -164,32 +183,41 @@ export default function AdminUserManagement() {
<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
<Shield className="w-3 h-3 mr-1" /> {t('userManagement.roles.admin')}
</Badge>
) : (
<Badge variant="outline">
<UserIcon className="w-3 h-3 mr-1" /> User
<UserIcon className="w-3 h-3 mr-1" /> {t('userManagement.roles.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="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">{t('userManagement.table.active')}</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
<Badge variant="destructive">{t('userManagement.table.inactive')}</Badge>
)}
</TableCell>
<TableCell>
{user.xp} XP (Lvl {user.level})
{user.xp} XP ({t('gamification.level', { level: user.level })})
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right flex items-center justify-end gap-2">
<Button
variant={user.isActive ? "destructive" : "outline"}
size="sm"
onClick={() => toggleActiveMutation.mutate(user.id)}
disabled={user.role === 'admin' && user.username === 'admin'} // Protect super admin heuristic
disabled={user.role === 'admin' && user.username === 'admin'}
>
{user.isActive ? 'Deactivate' : 'Activate'}
{user.isActive ? t('userManagement.table.deactivate') : t('userManagement.table.activate')}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteUser(user)}
disabled={user.role === 'admin' && user.username === 'admin'}
>
{t('common.delete')}
</Button>
</TableCell>
</TableRow>
@@ -198,6 +226,35 @@ export default function AdminUserManagement() {
</Table>
</CardContent>
</Card>
<Dialog open={!!deleteUser} onOpenChange={(open) => !open && setDeleteUser(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('userManagement.deleteUser')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
{t('userManagement.deleteConfirm', { username: deleteUser?.username })}
</p>
<Label>{t('userManagement.typeToConfirm')}: <span className="font-bold select-all">{deleteUser?.username}</span></Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={deleteUser?.username}
/>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDeleteUser(null)}>{t('common.cancel')}</Button>
<Button
variant="destructive"
onClick={() => deleteUser && deleteUserMutation.mutate(deleteUser.id)}
disabled={confirmText !== deleteUser?.username || deleteUserMutation.isPending}
>
{deleteUserMutation.isPending ? t('common.loading') : t('common.delete')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+97 -56
View File
@@ -1,10 +1,12 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useLocation } from "wouter";
import { useLocation, Link } 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 { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
@@ -22,12 +24,15 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BrainCircuit } from "lucide-react";
export default function AuthPage() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState("login");
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
queryKey: ["/api/settings/public"],
@@ -92,10 +97,9 @@ export default function AuthPage() {
<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>
<h1 className="text-4xl font-bold tracking-tight">{t('auth.heroTitle')}</h1>
<p className="text-lg text-zinc-400">
Master your productivity with AI-driven task management, gamified
achievements, and intelligent focus modes.
{t('auth.heroSubtitle')}
</p>
</div>
</div>
@@ -106,52 +110,61 @@ export default function AuthPage() {
<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>
<CardTitle className="text-2xl font-bold">{t('auth.welcomeBack')}</CardTitle>
<CardDescription>
Sign in to your account
{settings?.registration_enabled && " or create a new one"} to get started
{settings?.registration_enabled
? t('auth.signInDesc')
: t('auth.signInDescNoReg')}
</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>
<div className="flex w-full mb-6 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
<button
onClick={() => setActiveTab("login")}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "login"
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t('auth.login')}
</button>
{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>
<button
onClick={() => setActiveTab("register")}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "register"
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t('auth.register')}
</button>
)}
</Tabs>
</div>
{activeTab === "login" ? (
<AuthForm
mode="login"
onSubmit={(data) => loginMutation.mutate(data)}
isLoading={loginMutation.isPending}
/>
) : (
settings?.registration_enabled ? (
<AuthForm
mode="register"
onSubmit={(data) => {
registerMutation.mutate(data as InsertUser, {
onError: (error) => {
// Handled in form
}
})
}}
isLoading={registerMutation.isPending}
registerMutation={registerMutation}
/>
) : (
<div className="p-4 text-center text-muted-foreground">{t('auth.registrationDisabled')}</div>
)
)}
</CardContent>
</Card>
</div>
@@ -170,8 +183,9 @@ function AuthForm({
isLoading: boolean;
registerMutation?: any; // Type accurately if possible, but 'any' for quick fix avoids generic complexities
}) {
const { t } = useTranslation();
const { toast } = useToast();
const form = useForm<InsertUser>({
const form = useForm<any>({
resolver: zodResolver(mode === "login" ? loginSchema : registerSchema),
defaultValues: {
username: "",
@@ -212,9 +226,9 @@ function AuthForm({
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{mode === 'login' ? 'Username or Email' : 'Username'}</FormLabel>
<FormLabel>{mode === 'login' ? t('auth.usernameOrEmail') : t('auth.username')}</FormLabel>
<FormControl>
<Input placeholder={mode === 'login' ? "Enter username or email" : "Choose a username"} {...field} />
<Input placeholder={mode === 'login' ? t('auth.enterUsernameOrEmail') : t('auth.chooseUsername')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -227,9 +241,9 @@ function AuthForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t('auth.email')}</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
<Input type="email" placeholder={t('auth.enterEmail')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -242,11 +256,11 @@ function AuthForm({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('auth.password')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
placeholder={t('auth.enterPassword')}
{...field}
/>
</FormControl>
@@ -254,14 +268,41 @@ function AuthForm({
</FormItem>
)}
/>
{mode === "login" && (
<div className="flex items-center justify-between">
<FormField
control={form.control}
name="rememberMe"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal cursor-pointer">
{t("auth.rememberMe")}
</FormLabel>
</FormItem>
)}
/>
<Link href="/forgot-password">
<Button variant="link" className="px-0 font-normal mt-0 h-auto text-muted-foreground hover:text-primary" type="button">
{t("auth.forgotPassword")}
</Button>
</Link>
</div>
)}
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading
? mode === "login"
? "Logging in..."
: "Creating account..."
? t('auth.loggingIn')
: t('auth.creatingAccount')
: mode === "login"
? "Sign In"
: "Create Account"}
? t('auth.signIn')
: t('auth.createAccount')}
</Button>
</form>
</Form>
+144
View File
@@ -0,0 +1,144 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "wouter";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardFooter,
} 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 { BrainCircuit, ArrowLeft } from "lucide-react";
export default function ForgotPasswordPage() {
const { t } = useTranslation();
const { toast } = useToast();
const [isSuccess, setIsSuccess] = useState(false);
const forgotPasswordSchema = z.object({
email: z.string().email(t("validation.email")),
});
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
const form = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: {
email: "",
},
});
const mutation = useMutation({
mutationFn: async (data: ForgotPasswordFormData) => {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to send reset email");
}
return res.json();
},
onSuccess: () => {
setIsSuccess(true);
toast({ title: t("auth.resetEmailSent") });
},
onError: (error: Error) => {
toast({
title: t("common.error"),
description: "Something went wrong. Please try again.",
variant: "destructive",
});
},
});
const onSubmit = (data: ForgotPasswordFormData) => {
mutation.mutate(data);
};
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">{t("app.title")}</h1>
<p className="text-lg text-zinc-400">
{t("auth.forgotPasswordDesc")}
</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">{t("auth.forgotPasswordTitle")}</CardTitle>
<CardDescription>
{t("auth.forgotPasswordDesc")}
</CardDescription>
</CardHeader>
<CardContent>
{isSuccess ? (
<div className="text-center space-y-4">
<div className="p-4 bg-green-500/10 text-green-600 rounded-lg">
<p>{t("auth.resetEmailSentDesc")}</p>
</div>
<p className="text-sm text-neutral-500">{t("auth.checkSpam")}</p>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t("auth.sending") : t("auth.sendResetLink")}
</Button>
</form>
</Form>
)}
</CardContent>
<CardFooter className="justify-center">
<Link href="/auth">
<Button variant="link" className="text-sm text-neutral-500">
<ArrowLeft className="w-4 h-4 mr-2" /> {t("auth.backToLogin")}
</Button>
</Link>
</CardFooter>
</Card>
</div>
</div>
);
}
+13 -10
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Loader2, Trophy, Medal } from "lucide-react";
@@ -11,6 +12,7 @@ interface LeaderboardUser {
}
export default function LeaderboardPage() {
const { t } = useTranslation();
const { data: leaderboard, isLoading } = useQuery<LeaderboardUser[]>({
queryKey: ["/api/leaderboard"],
});
@@ -30,15 +32,15 @@ export default function LeaderboardPage() {
<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>
<h1 className="text-3xl font-bold tracking-tight">{t('leaderboardPage.title')}</h1>
<p className="text-muted-foreground">{t('leaderboardPage.subtitle')}</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>
<CardTitle>{t('leaderboardPage.globalRankings')}</CardTitle>
<CardDescription>{t('leaderboardPage.globalRankingsDesc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
@@ -46,9 +48,9 @@ export default function LeaderboardPage() {
<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'
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">
@@ -58,19 +60,19 @@ export default function LeaderboardPage() {
</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>
<span className="text-xs text-muted-foreground">{t('gamification.level', { 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>
<span className="text-xs text-muted-foreground uppercase tracking-wider">{t('leaderboardPage.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!
{t('leaderboardPage.noUsers')}
</div>
)}
</div>
@@ -79,3 +81,4 @@ export default function LeaderboardPage() {
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { Link, useLocation } from "wouter";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardFooter,
} 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 { BrainCircuit, ArrowLeft } from "lucide-react";
export default function ResetPasswordPage() {
const { t } = useTranslation();
const { toast } = useToast();
const [location, setLocation] = useLocation();
const [isSuccess, setIsSuccess] = useState(false);
// Parse query params manually since wouter doesn't have a hook for it built-in easily for this version?
// Actually window.location.search is easy enough
const searchParams = new URLSearchParams(window.location.search);
const token = searchParams.get("token");
const resetPasswordSchema = z.object({
password: z.string().min(6, t("validation.minLength", { min: 6 })),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: t("validation.passwordMatch"),
path: ["confirmPassword"],
});
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
const form = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
});
const mutation = useMutation({
mutationFn: async (data: ResetPasswordFormData) => {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword: data.password }),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || "Failed to reset password");
}
return res.json();
},
onSuccess: () => {
setIsSuccess(true);
toast({ title: t("auth.resetSuccess") });
setTimeout(() => setLocation("/auth"), 3000);
},
onError: (error: Error) => {
toast({
title: t("common.error"),
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: ResetPasswordFormData) => {
if (!token) {
toast({ title: t("auth.invalidToken"), description: t("auth.missingToken"), variant: "destructive" });
return;
}
mutation.mutate(data);
};
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md shadow-xl border-border/50">
<CardHeader className="text-center">
<CardTitle className="text-destructive">{t("auth.invalidToken")}</CardTitle>
<CardDescription>{t("auth.missingToken")}</CardDescription>
</CardHeader>
<CardFooter className="justify-center">
<Link href="/auth">
<Button variant="link">{t("auth.backToLogin")}</Button>
</Link>
</CardFooter>
</Card>
</div>
)
}
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">{t("app.title")}</h1>
<p className="text-lg text-zinc-400">
{t("auth.resetPasswordDesc")}
</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">{t("auth.resetPasswordTitle")}</CardTitle>
<CardDescription>
{t("auth.resetPasswordDesc")}
</CardDescription>
</CardHeader>
<CardContent>
{isSuccess ? (
<div className="text-center space-y-4">
<div className="p-4 bg-green-500/10 text-green-600 rounded-lg">
<p>{t("auth.resetSuccess")}</p>
<p className="text-sm mt-2">{t("auth.redirecting")}</p>
</div>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("auth.newPassword")}</FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t("auth.confirmPassword")}</FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t("auth.resetting") : t("auth.resetBtn")}
</Button>
</form>
</Form>
)}
</CardContent>
{isSuccess && (
<CardFooter className="justify-center">
<Link href="/auth">
<Button variant="link" className="text-sm text-neutral-500">
<ArrowLeft className="w-4 h-4 mr-2" /> {t("auth.backToLogin")}
</Button>
</Link>
</CardFooter>
)}
</Card>
</div>
</div>
);
}
+47 -11
View File
@@ -1,20 +1,56 @@
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { Link } from "wouter";
// This image would ideally be imported, but for now we reference the public asset we would have (conceptually) or generated
// Since we generated it to an artifact, we need to move it to proper location or assume a path.
// For now, I will assume it's copied to /public/404-illustration.png by a separate command or use a placeholder if not.
// But as I am an agent, I will rely on the user to see the artifact.
// Wait, I can't easily reference the artifact URL in the code unless I copy it to the public dir.
// I'll assume for this design I use a standard nice layout, and if I could I would put the image there.
// I will use a placeholder styling or the generated image if I can move it.
// Let's copy the generated image to the public folder first!
export default function NotFound() {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md mx-4">
<CardContent className="pt-6">
<div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
<div className="min-h-screen w-full flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-2xl mx-auto shadow-xl border-dashed border-2 overflow-hidden bg-card text-card-foreground">
<div className="md:flex">
<div className="md:w-1/2 bg-muted/30 flex items-center justify-center p-8">
{/*
In a real app, I'd move the generated image to public/assets/404.png
For now, I'll use a high-quality SVG placeholder or just the <img> tag pointing to where I'll put it.
I'll Move the artifact to client/public/404.png in the next step.
*/}
<img
src="/404-illustration.png"
alt="Damaged Task List"
className="w-full h-auto object-contain drop-shadow-lg transform rotate-3 hover:rotate-0 transition-transform duration-500"
/>
</div>
<div className="md:w-1/2 p-8 flex flex-col justify-center">
<div className="flex items-center gap-2 mb-4">
<AlertCircle className="h-6 w-6 text-destructive" />
<span className="text-sm font-semibold text-destructive tracking-wider uppercase">Error 404</span>
</div>
<p className="mt-4 text-sm text-gray-600">
Did you forget to add the page to the router?
</p>
</CardContent>
<h1 className="text-4xl font-extrabold text-foreground mb-4 tracking-tight">
Page Not Found
</h1>
<p className="text-muted-foreground mb-8 leading-relaxed">
Oops! It looks like this task got lost in the shuffle. The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.
</p>
<Link href="/">
<Button className="w-full sm:w-auto gap-2 group">
<ArrowLeft className="h-4 w-4 group-hover:-translate-x-1 transition-transform" />
Back to Dashboard
</Button>
</Link>
</div>
</div>
</Card>
</div>
);
+245 -18
View File
@@ -6,7 +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, User as UserIcon, ShieldCheck } from 'lucide-react'; // Added ShieldCheck
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { ShareAccessModal } from '@/components/ShareAccessModal';
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
import { UpdateProfileModal } from '@/components/UpdateProfileModal';
import { ShareLabelModal } from '@/components/ShareLabelModal';
import { Label } from '@shared/schema';
import { User } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
@@ -31,6 +36,11 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
const [isShareAccessOpen, setIsShareAccessOpen] = useState(false);
const [isShareLabelOpen, setIsShareLabelOpen] = useState(false);
const [sharingLabel, setSharingLabel] = useState<Label | null>(null);
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [isUpdateProfileOpen, setIsUpdateProfileOpen] = useState(false);
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
@@ -39,17 +49,17 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
};
const privacyMutation = useMutation({
mutationFn: async (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
mutationFn: async (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean; aiEnabled?: boolean }) => {
const res = await apiRequest("PATCH", "/api/user/privacy", updates);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Privacy settings updated" });
toast({ title: "Settings updated" });
},
});
const handlePrivacyUpdate = (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
const handlePrivacyUpdate = (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean; aiEnabled?: boolean }) => {
privacyMutation.mutate(updates);
};
@@ -130,6 +140,37 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
}
};
const handleShareLabel = (label: Label) => {
setSharingLabel(label);
setIsShareLabelOpen(true);
};
const generateApiKeyMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/user/apikey", {});
return res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: t('settings.mcp.generated') });
},
});
const revokeApiKeyMutation = useMutation({
mutationFn: async () => {
await apiRequest("DELETE", "/api/user/apikey");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: t('settings.mcp.revoked') });
},
});
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: "Copied!" });
}
return (
<div className="space-y-6">
<div>
@@ -143,20 +184,158 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Account
{t('settings.account.title')}
</CardTitle>
<CardDescription>
Manage your account settings
{t('settings.account.description')}
</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>
<p className="text-sm font-medium leading-none">{t('settings.account.username')}</p>
<p className="text-sm text-muted-foreground">{user?.username || t('common.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 className="space-y-1" data-testid="container-email">
<p className="text-sm font-medium leading-none">{t('auth.email')}</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground" data-testid="text-email">{user?.email || 'No email set'}</p>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setIsUpdateProfileOpen(true)} data-testid="button-edit-email">
<Edit className="h-3 w-3" />
</Button>
</div>
</div>
{user?.role === 'admin' && (
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{t('settings.account.userId')}</p>
<p className="text-sm text-muted-foreground font-mono">{user?.id || '...'}</p>
</div>
)}
<div className="pt-2">
<Button variant="outline" onClick={() => setIsChangePasswordOpen(true)} data-testid="button-change-password">
{t('auth.changePassword')}
</Button>
</div>
</CardContent>
</Card>
{user && (
<>
<UpdateProfileModal open={isUpdateProfileOpen} onOpenChange={setIsUpdateProfileOpen} user={user} />
<ChangePasswordModal open={isChangePasswordOpen} onOpenChange={setIsChangePasswordOpen} />
</>
)}
{/* Social & Privacy */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserIcon className="w-5 h-5" />
{t('settings.social.title')}
</CardTitle>
<CardDescription>
{t('settings.social.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('settings.social.publicLeaderboard')}</p>
<p className="text-sm text-muted-foreground">{t('settings.social.publicLeaderboardDesc')}</p>
</div>
<Switch
checked={user?.showOnLeaderboard}
onCheckedChange={(checked) => handlePrivacyUpdate({ showOnLeaderboard: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('settings.social.searchable')}</p>
<p className="text-sm text-muted-foreground">{t('settings.social.searchableDesc')}</p>
</div>
<Switch
checked={user?.isSearchable}
onCheckedChange={(checked) => handlePrivacyUpdate({ isSearchable: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('settings.ai.enableUser')}</p>
<p className="text-sm text-muted-foreground">{t('settings.ai.enableUserDesc')}</p>
</div>
<Switch
checked={user?.aiEnabled}
onCheckedChange={(checked) => handlePrivacyUpdate({ aiEnabled: checked })}
/>
</div>
<div className="pt-2">
<Button variant="outline" onClick={() => setIsShareAccessOpen(true)}>
<Share2 className="w-4 h-4 mr-2" />
{t('settings.social.shareAccess')}
</Button>
</div>
</CardContent>
</Card>
<ShareAccessModal open={isShareAccessOpen} onOpenChange={setIsShareAccessOpen} />
{/* MCP Integration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="w-5 h-5" />
{t('settings.mcp.title')}
</CardTitle>
<CardDescription>{t('settings.mcp.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('settings.mcp.status')}</p>
</div>
<div className="flex items-center gap-2 text-green-500">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="font-medium">{t('settings.mcp.running')}</span>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('settings.mcp.url')}</p>
<div className="flex gap-2">
<Input readOnly value={`${window.location.protocol}//${window.location.host}/api/mcp/sse`} />
<Button variant="outline" size="icon" onClick={() => copyToClipboard(`${window.location.protocol}//${window.location.host}/api/mcp/sse`)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('settings.mcp.apiKey')}</p>
{user?.apiKey ? (
<div className="flex gap-2">
<Input type="password" readOnly value={user.apiKey} />
{/* Show full key on click/copy only, usually concealed */}
<Button variant="outline" size="icon" onClick={() => copyToClipboard(user.apiKey!)}>
<Copy className="w-4 h-4" />
</Button>
<Button variant="destructive" onClick={() => revokeApiKeyMutation.mutate()} disabled={revokeApiKeyMutation.isPending}>
{t('settings.mcp.revoke')}
</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t('settings.mcp.noKey')}</p>
<Button onClick={() => generateApiKeyMutation.mutate()} disabled={generateApiKeyMutation.isPending}>
{t('settings.mcp.generate')}
</Button>
</div>
)}
</div>
<div className="bg-muted/50 p-3 rounded-lg text-sm text-muted-foreground">
{t('settings.mcp.instructions')}
</div>
</CardContent>
</Card>
@@ -167,16 +346,21 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-primary" />
Administration
{t('settings.admin.title')}
</CardTitle>
<CardDescription>
System-wide settings and user management
{t('settings.admin.description')}
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setLocation("/admin/users")} className="w-full sm:w-auto">
Manage Users & Registration
</Button>
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={() => setLocation("/admin/users")} className="w-full sm:w-auto">
{t('settings.admin.manageUsers')}
</Button>
<Button onClick={() => setLocation("/admin/settings")} variant="outline" className="w-full sm:w-auto">
{t('settings.admin.smtpSettings')}
</Button>
</div>
</CardContent>
</Card>
)}
@@ -193,7 +377,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</CardDescription>
</CardHeader>
<CardContent>
<Select value={i18n.language} onValueChange={changeLanguage}>
<Select value={i18n.language} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-full sm:w-64" data-testid="select-language">
<SelectValue />
</SelectTrigger>
@@ -315,6 +499,17 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</span>
</div>
<div className="flex items-center gap-1">
{label.creatorId === user?.id && (
<Button
variant="ghost"
size="icon"
className="w-6 h-6 text-muted-foreground hover:text-primary"
onClick={() => handleShareLabel(label)}
title={t('settings.labels.share')}
>
<Share2 className="w-3 h-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -359,6 +554,13 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</CardContent>
</Card>
<ShareLabelModal
open={isShareLabelOpen}
onOpenChange={setIsShareLabelOpen}
label={sharingLabel}
currentUser={user}
/>
{/* Project Templates */}
<Card data-testid="card-project-templates">
<CardHeader>
@@ -380,6 +582,31 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</Button>
</CardContent>
</Card>
</div>
{/* Admin Section */}
{
user?.role === 'admin' && (
<Card className="border-destructive/20 bg-destructive/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<ShieldCheck className="w-5 h-5" />
{t('settings.admin.title')}
</CardTitle>
<CardDescription>{t('settings.admin.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col sm:flex-row gap-4">
<Button variant="outline" onClick={() => setLocation('/admin/users')}>
<UserIcon className="w-4 h-4 mr-2" />
{t('settings.admin.manageUsers')}
</Button>
<Button variant="outline" onClick={() => setLocation('/admin/settings')}>
<SettingsIcon className="w-4 h-4 mr-2" />
{t('settings.ai.title')} / {t('settings.smtp.title')}
</Button>
</CardContent>
</Card>
)
}
</div >
);
}