Files
task-team/apps/tasks/app/projects/page.tsx
Admin b3c6999218 Fix SSL SAN cert + React hydration #423
- SAN cert covers all 5 PWA domains (tasks,cal,plans,goals,chat)
- i18n hydration: SSR uses cs default, localStorage after mount
- Matches ThemeProvider pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:56:25 +00:00

240 lines
9.6 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
import {
getProjects,
createProject,
deleteProject,
Project,
} from "@/lib/api";
export default function ProjectsPage() {
const { token, user } = useAuth();
const { t, locale } = useTranslation();
const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [formName, setFormName] = useState("");
const [formDesc, setFormDesc] = useState("");
const [formColor, setFormColor] = useState("#3B82F6");
const [formIcon, setFormIcon] = useState("\ud83d\udcc1");
const COLORS = ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899", "#6366F1", "#14B8A6"];
const ICONS = ["\ud83d\udcc1", "\ud83d\ude80", "\ud83d\udca1", "\ud83c\udfaf", "\ud83d\udee0\ufe0f", "\ud83c\udf1f", "\ud83d\udcca", "\ud83d\udd25"];
const loadData = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const res = await getProjects(token);
setProjects(res.data || []);
} catch (err) {
console.error("Load error:", err);
setError(t("common.error"));
} finally {
setLoading(false);
}
}, [token, t]);
useEffect(() => {
if (!token) {
router.replace("/login");
return;
}
loadData();
}, [token, router, loadData]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!token || !formName.trim()) return;
try {
await createProject(token, {
name: formName.trim(),
description: formDesc,
color: formColor,
icon: formIcon,
owner_id: user?.id,
});
setFormName("");
setFormDesc("");
setFormColor("#3B82F6");
setFormIcon("\ud83d\udcc1");
setShowForm(false);
loadData();
} catch (err) {
console.error("Create error:", err);
setError(t("common.error"));
}
}
async function handleDelete(id: string) {
if (!token) return;
if (!confirm(t("tasks.confirmDelete"))) return;
try {
await deleteProject(token, id);
loadData();
} catch (err) {
console.error("Delete error:", err);
setError(t("common.error"));
}
}
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
function formatDate(d: string) {
return new Date(d).toLocaleDateString(dateLocale, { day: "numeric", month: "short" });
}
if (!token) return null;
return (
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold dark:text-white">{t("nav.projects")}</h1>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors min-h-[44px]"
>
{showForm ? t("tasks.form.cancel") : `+ ${t("projects.add")}`}
</button>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">{t("tasks.close")}</button>
</div>
)}
{/* Create form */}
{showForm && (
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.title")}</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder={t("projects.namePlaceholder")}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-h-[44px]"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.description")}</label>
<textarea
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder={t("projects.descPlaceholder")}
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 min-h-[80px] resize-none"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("projects.color")}</label>
<div className="flex gap-1.5 flex-wrap">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setFormColor(c)}
className={`w-8 h-8 rounded-full border-2 transition-all ${formColor === c ? "border-gray-900 dark:border-white scale-110" : "border-transparent"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("projects.icon")}</label>
<div className="flex gap-1.5 flex-wrap">
{ICONS.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setFormIcon(ic)}
className={`w-8 h-8 rounded-lg flex items-center justify-center text-lg border-2 transition-all ${formIcon === ic ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-gray-200 dark:border-gray-700"}`}
>
{ic}
</button>
))}
</div>
</div>
</div>
<button
type="submit"
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors min-h-[44px]"
>
{t("projects.add")}
</button>
</form>
)}
{/* Projects list */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-16">
<div className="text-5xl mb-4 opacity-50">{"\ud83d\udcc2"}</div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t("projects.empty")}</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">{t("projects.createFirst")}</p>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<div
key={project.id}
className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl flex-shrink-0"
style={{ backgroundColor: (project.color || "#3B82F6") + "15" }}
>
{project.icon || "\ud83d\udcc1"}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{project.name}</h3>
{project.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">{project.description}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-400 dark:text-gray-500">
<span>{t("projects.tasks")}: {project.task_count || 0}</span>
<span>{t("projects.members")}: {project.members?.length || 0}</span>
<span>{formatDate(project.created_at)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
<button
onClick={() => handleDelete(project.id)}
className="p-2 text-gray-400 hover:text-red-500 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
title={t("tasks.delete")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}