Prettier format pending files
This commit is contained in:
parent
e5a5607728
commit
df2b52af71
9 changed files with 531 additions and 148 deletions
|
|
@ -74,7 +74,10 @@ const App = () => (
|
||||||
<Route path="/feed" element={<Feed />} />
|
<Route path="/feed" element={<Feed />} />
|
||||||
<Route path="/teams" element={<Teams />} />
|
<Route path="/teams" element={<Teams />} />
|
||||||
<Route path="/projects/new" element={<ProjectsNew />} />
|
<Route path="/projects/new" element={<ProjectsNew />} />
|
||||||
<Route path="/projects/:projectId/board" element={<ProjectBoard />} />
|
<Route
|
||||||
|
path="/projects/:projectId/board"
|
||||||
|
element={<ProjectBoard />}
|
||||||
|
/>
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/profile/me" element={<Profile />} />
|
<Route path="/profile/me" element={<Profile />} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { aethexUserService, aethexNotificationService } from "@/lib/aethex-database-adapter";
|
import {
|
||||||
|
aethexUserService,
|
||||||
|
aethexNotificationService,
|
||||||
|
} from "@/lib/aethex-database-adapter";
|
||||||
|
|
||||||
export type TeamVisibility = "public" | "private";
|
export type TeamVisibility = "public" | "private";
|
||||||
export type MembershipRole = "owner" | "admin" | "member";
|
export type MembershipRole = "owner" | "admin" | "member";
|
||||||
|
|
@ -11,14 +14,21 @@ export const aethexCollabService = {
|
||||||
async listMyTeams(userId: string) {
|
async listMyTeams(userId: string) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("team_memberships")
|
.from("team_memberships")
|
||||||
.select("team_id, teams:team_id ( id, name, slug, description, visibility, created_at )")
|
.select(
|
||||||
|
"team_id, teams:team_id ( id, name, slug, description, visibility, created_at )",
|
||||||
|
)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
if (error) return [] as any[];
|
if (error) return [] as any[];
|
||||||
return (data || []) as any[];
|
return (data || []) as any[];
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTeam(ownerId: string, name: string, description?: string | null, visibility: TeamVisibility = "private") {
|
async createTeam(
|
||||||
|
ownerId: string,
|
||||||
|
name: string,
|
||||||
|
description?: string | null,
|
||||||
|
visibility: TeamVisibility = "private",
|
||||||
|
) {
|
||||||
// Ensure the owner has a user_profiles row to satisfy FK
|
// Ensure the owner has a user_profiles row to satisfy FK
|
||||||
try {
|
try {
|
||||||
await aethexUserService.getCurrentUser();
|
await aethexUserService.getCurrentUser();
|
||||||
|
|
@ -26,7 +36,12 @@ export const aethexCollabService = {
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("teams")
|
.from("teams")
|
||||||
.insert({ owner_id: ownerId, name, description: description || null, visibility })
|
.insert({
|
||||||
|
owner_id: ownerId,
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
visibility,
|
||||||
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw new Error(error.message || "Unable to create team");
|
if (error) throw new Error(error.message || "Unable to create team");
|
||||||
|
|
@ -52,7 +67,11 @@ export const aethexCollabService = {
|
||||||
return team;
|
return team;
|
||||||
},
|
},
|
||||||
|
|
||||||
async addTeamMember(teamId: string, userId: string, role: MembershipRole = "member") {
|
async addTeamMember(
|
||||||
|
teamId: string,
|
||||||
|
userId: string,
|
||||||
|
role: MembershipRole = "member",
|
||||||
|
) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("team_memberships")
|
.from("team_memberships")
|
||||||
.insert({ team_id: teamId, user_id: userId, role });
|
.insert({ team_id: teamId, user_id: userId, role });
|
||||||
|
|
@ -60,7 +79,11 @@ export const aethexCollabService = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
async addProjectMember(projectId: string, userId: string, role: ProjectRole = "contributor") {
|
async addProjectMember(
|
||||||
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
role: ProjectRole = "contributor",
|
||||||
|
) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("project_members")
|
.from("project_members")
|
||||||
.insert({ project_id: projectId, user_id: userId, role });
|
.insert({ project_id: projectId, user_id: userId, role });
|
||||||
|
|
@ -70,7 +93,9 @@ export const aethexCollabService = {
|
||||||
async listProjectMembers(projectId: string) {
|
async listProjectMembers(projectId: string) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("project_members")
|
.from("project_members")
|
||||||
.select("user_id, role, user:user_id ( id, full_name, username, avatar_url )")
|
.select(
|
||||||
|
"user_id, role, user:user_id ( id, full_name, username, avatar_url )",
|
||||||
|
)
|
||||||
.eq("project_id", projectId);
|
.eq("project_id", projectId);
|
||||||
if (error) return [] as any[];
|
if (error) return [] as any[];
|
||||||
return (data || []) as any[];
|
return (data || []) as any[];
|
||||||
|
|
@ -87,10 +112,22 @@ export const aethexCollabService = {
|
||||||
return (data || []) as any[];
|
return (data || []) as any[];
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTask(projectId: string, title: string, description?: string | null, assigneeId?: string | null, dueDate?: string | null) {
|
async createTask(
|
||||||
|
projectId: string,
|
||||||
|
title: string,
|
||||||
|
description?: string | null,
|
||||||
|
assigneeId?: string | null,
|
||||||
|
dueDate?: string | null,
|
||||||
|
) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("project_tasks")
|
.from("project_tasks")
|
||||||
.insert({ project_id: projectId, title, description: description || null, assignee_id: assigneeId || null, due_date: dueDate || null })
|
.insert({
|
||||||
|
project_id: projectId,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
assignee_id: assigneeId || null,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw new Error(error.message || "Unable to create task");
|
if (error) throw new Error(error.message || "Unable to create task");
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,10 @@ export const aethexSocialService = {
|
||||||
const resp = await fetch("/api/social/follow", {
|
const resp = await fetch("/api/social/follow", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ follower_id: followerId, following_id: followingId }),
|
body: JSON.stringify({
|
||||||
|
follower_id: followerId,
|
||||||
|
following_id: followingId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await resp.text());
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
},
|
},
|
||||||
|
|
@ -65,7 +68,10 @@ export const aethexSocialService = {
|
||||||
const resp = await fetch("/api/social/unfollow", {
|
const resp = await fetch("/api/social/unfollow", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ follower_id: followerId, following_id: followingId }),
|
body: JSON.stringify({
|
||||||
|
follower_id: followerId,
|
||||||
|
following_id: followingId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await resp.text());
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
},
|
},
|
||||||
|
|
@ -148,7 +154,11 @@ export const aethexSocialService = {
|
||||||
const resp = await fetch("/api/social/endorse", {
|
const resp = await fetch("/api/social/endorse", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ endorser_id: endorserId, endorsed_id: endorsedId, skill }),
|
body: JSON.stringify({
|
||||||
|
endorser_id: endorserId,
|
||||||
|
endorsed_id: endorsedId,
|
||||||
|
skill,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await resp.text());
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -509,10 +509,30 @@ export default function Dashboard() {
|
||||||
const showProfileSetup = !profileComplete;
|
const showProfileSetup = !profileComplete;
|
||||||
|
|
||||||
const statsDisplay = [
|
const statsDisplay = [
|
||||||
{ label: "Active Projects", value: stats.activeProjects, icon: Rocket, color: "from-blue-500 to-purple-600" },
|
{
|
||||||
{ label: "Completed Tasks", value: stats.completedTasks, icon: Trophy, color: "from-green-500 to-blue-600" },
|
label: "Active Projects",
|
||||||
{ label: "Team Members", value: stats.teamMembers, icon: Users, color: "from-purple-500 to-pink-600" },
|
value: stats.activeProjects,
|
||||||
{ label: "Performance Score", value: stats.performanceScore, icon: TrendingUp, color: "from-orange-500 to-red-600" },
|
icon: Rocket,
|
||||||
|
color: "from-blue-500 to-purple-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Completed Tasks",
|
||||||
|
value: stats.completedTasks,
|
||||||
|
icon: Trophy,
|
||||||
|
color: "from-green-500 to-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Team Members",
|
||||||
|
value: stats.teamMembers,
|
||||||
|
icon: Users,
|
||||||
|
color: "from-purple-500 to-pink-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Performance Score",
|
||||||
|
value: stats.performanceScore,
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: "from-orange-500 to-red-600",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getProgressPercentage = (project: any) => {
|
const getProgressPercentage = (project: any) => {
|
||||||
|
|
@ -561,10 +581,18 @@ export default function Dashboard() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ title: "Start New Project", icon: Rocket, color: "from-blue-500 to-purple-600" },
|
{
|
||||||
|
title: "Start New Project",
|
||||||
|
icon: Rocket,
|
||||||
|
color: "from-blue-500 to-purple-600",
|
||||||
|
},
|
||||||
{ title: "Create Team", icon: Users, color: "from-green-500 to-blue-600" },
|
{ title: "Create Team", icon: Users, color: "from-green-500 to-blue-600" },
|
||||||
{ title: "Access Labs", icon: Zap, color: "from-yellow-500 to-orange-600" },
|
{ title: "Access Labs", icon: Zap, color: "from-yellow-500 to-orange-600" },
|
||||||
{ title: "View Analytics", icon: BarChart3, color: "from-purple-500 to-pink-600" },
|
{
|
||||||
|
title: "View Analytics",
|
||||||
|
icon: BarChart3,
|
||||||
|
color: "from-purple-500 to-pink-600",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -1200,7 +1228,12 @@ export default function Dashboard() {
|
||||||
Your active development projects
|
Your active development projects
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="hover-lift" onClick={() => navigate("/projects")}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover-lift"
|
||||||
|
onClick={() => navigate("/projects")}
|
||||||
|
>
|
||||||
View All
|
View All
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1266,7 +1299,13 @@ export default function Dashboard() {
|
||||||
>
|
>
|
||||||
{getPriorityFromTech(project.technologies || [])}
|
{getPriorityFromTech(project.technologies || [])}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate(`/projects/${project.id}/board`)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/projects/${project.id}/board`)
|
||||||
|
}
|
||||||
|
>
|
||||||
Open Board
|
Open Board
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1406,14 +1445,27 @@ export default function Dashboard() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{teams.length === 0 ? (
|
{teams.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">No teams yet.</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No teams yet.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
teams.slice(0, 6).map((t: any) => {
|
teams.slice(0, 6).map((t: any) => {
|
||||||
const team = (t as any).teams || t;
|
const team = (t as any).teams || t;
|
||||||
return (
|
return (
|
||||||
<div key={team.id} className="flex items-center justify-between p-3 rounded border border-border/40">
|
<div
|
||||||
<div className="font-medium text-sm">{team.name}</div>
|
key={team.id}
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate("/teams")}>Open</Button>
|
className="flex items-center justify-between p-3 rounded border border-border/40"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{team.name}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate("/teams")}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -1428,15 +1480,34 @@ export default function Dashboard() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{invites.length === 0 ? (
|
{invites.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">No invites yet.</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No invites yet.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
invites.slice(0, 6).map((inv: any) => (
|
invites.slice(0, 6).map((inv: any) => (
|
||||||
<div key={inv.id} className="flex items-center justify-between p-3 rounded border border-border/40">
|
<div
|
||||||
|
key={inv.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded border border-border/40"
|
||||||
|
>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">{inv.invitee_email}</div>
|
<div className="font-medium">
|
||||||
<div className="text-xs text-muted-foreground">{inv.status}</div>
|
{inv.invitee_email}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{inv.status}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigator.clipboard.writeText(`${location.origin}/login?invite=${inv.token}`)}>Copy link</Button>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${location.origin}/login?invite=${inv.token}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,39 +6,68 @@ export default function Privacy() {
|
||||||
<div className="min-h-screen bg-aethex-gradient py-12">
|
<div className="min-h-screen bg-aethex-gradient py-12">
|
||||||
<div className="container mx-auto px-4 max-w-4xl space-y-8">
|
<div className="container mx-auto px-4 max-w-4xl space-y-8">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-3xl font-bold text-gradient-purple">Privacy Policy</h1>
|
<h1 className="text-3xl font-bold text-gradient-purple">
|
||||||
<p className="text-sm text-muted-foreground">Effective date: 2025-10-18</p>
|
Privacy Policy
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This Privacy Policy explains how AeThex ("we", "us") collects, uses, shares, and protects
|
Effective date: 2025-10-18
|
||||||
information when you use our products, sites, and services (the "Services").
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This Privacy Policy explains how AeThex ("we", "us") collects,
|
||||||
|
uses, shares, and protects information when you use our products,
|
||||||
|
sites, and services (the "Services").
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Information We Collect</h2>
|
<h2 className="font-semibold">Information We Collect</h2>
|
||||||
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
||||||
<li>Account data: name, username, email, profile details, social links.</li>
|
<li>
|
||||||
<li>Content: posts, comments, projects, teams, endorsements, activity metadata.</li>
|
Account data: name, username, email, profile details, social
|
||||||
<li>Usage data: device/browser information, pages visited, interactions, approximate location.</li>
|
links.
|
||||||
<li>Cookies & similar: session and preference cookies for authentication and settings.</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
Content: posts, comments, projects, teams, endorsements,
|
||||||
|
activity metadata.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Usage data: device/browser information, pages visited,
|
||||||
|
interactions, approximate location.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Cookies & similar: session and preference cookies for
|
||||||
|
authentication and settings.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">How We Use Information</h2>
|
<h2 className="font-semibold">How We Use Information</h2>
|
||||||
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
||||||
<li>Provide and improve the Services, including social, projects, teams, and notifications.</li>
|
<li>
|
||||||
<li>Security, abuse prevention, fraud detection, and diagnostics.</li>
|
Provide and improve the Services, including social, projects,
|
||||||
<li>Personalization (e.g., recommendations, feed ranking) and aggregated analytics.</li>
|
teams, and notifications.
|
||||||
<li>Communications: transactional emails (verification, invites, alerts) and product updates.</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
Security, abuse prevention, fraud detection, and diagnostics.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Personalization (e.g., recommendations, feed ranking) and
|
||||||
|
aggregated analytics.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Communications: transactional emails (verification, invites,
|
||||||
|
alerts) and product updates.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Legal Bases (EEA/UK)</h2>
|
<h2 className="font-semibold">Legal Bases (EEA/UK)</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We process data under: (i) Performance of a contract (providing core features), (ii) Legitimate
|
We process data under: (i) Performance of a contract (providing
|
||||||
interests (security, analytics, product improvement), (iii) Consent (where required), and (iv)
|
core features), (ii) Legitimate interests (security, analytics,
|
||||||
|
product improvement), (iii) Consent (where required), and (iv)
|
||||||
Compliance with legal obligations.
|
Compliance with legal obligations.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -46,8 +75,9 @@ export default function Privacy() {
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Sharing & Service Providers</h2>
|
<h2 className="font-semibold">Sharing & Service Providers</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We do not sell your personal information. We use trusted sub-processors to operate the platform:
|
We do not sell your personal information. We use trusted
|
||||||
Supabase (auth, database, storage), Vercel/Netlify (hosting/CDN), and Resend (email). These
|
sub-processors to operate the platform: Supabase (auth, database,
|
||||||
|
storage), Vercel/Netlify (hosting/CDN), and Resend (email). These
|
||||||
providers process data on our behalf under appropriate agreements.
|
providers process data on our behalf under appropriate agreements.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -55,24 +85,31 @@ export default function Privacy() {
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">International Transfers</h2>
|
<h2 className="font-semibold">International Transfers</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Data may be processed in the United States and other countries. Where applicable, we rely on
|
Data may be processed in the United States and other countries.
|
||||||
appropriate safeguards (e.g., SCCs) for cross-border transfers.
|
Where applicable, we rely on appropriate safeguards (e.g., SCCs)
|
||||||
|
for cross-border transfers.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Data Retention</h2>
|
<h2 className="font-semibold">Data Retention</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We retain data for as long as needed to provide Services, comply with law, resolve disputes,
|
We retain data for as long as needed to provide Services, comply
|
||||||
and enforce agreements. You may request deletion of your account data, subject to legal holds.
|
with law, resolve disputes, and enforce agreements. You may
|
||||||
|
request deletion of your account data, subject to legal holds.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Your Rights</h2>
|
<h2 className="font-semibold">Your Rights</h2>
|
||||||
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
||||||
<li>Access, correction, deletion, and portability of your data.</li>
|
<li>
|
||||||
<li>Object to or restrict certain processing; withdraw consent where applicable.</li>
|
Access, correction, deletion, and portability of your data.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Object to or restrict certain processing; withdraw consent where
|
||||||
|
applicable.
|
||||||
|
</li>
|
||||||
<li>Manage notifications and email preferences in-app.</li>
|
<li>Manage notifications and email preferences in-app.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -80,31 +117,35 @@ export default function Privacy() {
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Security</h2>
|
<h2 className="font-semibold">Security</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We use industry-standard measures to protect data in transit and at rest. No method of
|
We use industry-standard measures to protect data in transit and
|
||||||
transmission or storage is 100% secure; you are responsible for safeguarding credentials.
|
at rest. No method of transmission or storage is 100% secure; you
|
||||||
|
are responsible for safeguarding credentials.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Children</h2>
|
<h2 className="font-semibold">Children</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Our Services are not directed to children under 13 (or as defined by local law). We do not
|
Our Services are not directed to children under 13 (or as defined
|
||||||
knowingly collect data from children. If you believe a child has provided data, contact us.
|
by local law). We do not knowingly collect data from children. If
|
||||||
|
you believe a child has provided data, contact us.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Changes</h2>
|
<h2 className="font-semibold">Changes</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We may update this Policy. Material changes will be announced via the app or email. Your
|
We may update this Policy. Material changes will be announced via
|
||||||
continued use after changes constitutes acceptance.
|
the app or email. Your continued use after changes constitutes
|
||||||
|
acceptance.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Contact</h2>
|
<h2 className="font-semibold">Contact</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
For privacy inquiries: privacy@aethex.biz. For support: support@aethex.biz.
|
For privacy inquiries: privacy@aethex.biz. For support:
|
||||||
|
support@aethex.biz.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ import Layout from "@/components/Layout";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
@ -10,7 +16,11 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { aethexCollabService } from "@/lib/aethex-collab-service";
|
import { aethexCollabService } from "@/lib/aethex-collab-service";
|
||||||
import LoadingScreen from "@/components/LoadingScreen";
|
import LoadingScreen from "@/components/LoadingScreen";
|
||||||
|
|
||||||
const columns: { key: "todo"|"doing"|"done"|"blocked"; title: string; hint: string }[] = [
|
const columns: {
|
||||||
|
key: "todo" | "doing" | "done" | "blocked";
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
}[] = [
|
||||||
{ key: "todo", title: "To do", hint: "Planned" },
|
{ key: "todo", title: "To do", hint: "Planned" },
|
||||||
{ key: "doing", title: "In progress", hint: "Active" },
|
{ key: "doing", title: "In progress", hint: "Active" },
|
||||||
{ key: "done", title: "Done", hint: "Completed" },
|
{ key: "done", title: "Done", hint: "Completed" },
|
||||||
|
|
@ -47,7 +57,12 @@ export default function ProjectBoard() {
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map: Record<string, any[]> = { todo: [], doing: [], done: [], blocked: [] };
|
const map: Record<string, any[]> = {
|
||||||
|
todo: [],
|
||||||
|
doing: [],
|
||||||
|
done: [],
|
||||||
|
blocked: [],
|
||||||
|
};
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
map[t.status || "todo"].push(t);
|
map[t.status || "todo"].push(t);
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +74,13 @@ export default function ProjectBoard() {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await aethexCollabService.createTask(projectId, title.trim(), description.trim() || null, null, null);
|
await aethexCollabService.createTask(
|
||||||
|
projectId,
|
||||||
|
title.trim(),
|
||||||
|
description.trim() || null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
await load();
|
await load();
|
||||||
|
|
@ -68,12 +89,18 @@ export default function ProjectBoard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const move = async (taskId: string, status: "todo"|"doing"|"done"|"blocked") => {
|
const move = async (
|
||||||
|
taskId: string,
|
||||||
|
status: "todo" | "doing" | "done" | "blocked",
|
||||||
|
) => {
|
||||||
await aethexCollabService.updateTaskStatus(taskId, status);
|
await aethexCollabService.updateTaskStatus(taskId, status);
|
||||||
await load();
|
await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading || isLoading) return <LoadingScreen message="Loading project..." showProgress duration={800} />;
|
if (loading || isLoading)
|
||||||
|
return (
|
||||||
|
<LoadingScreen message="Loading project..." showProgress duration={800} />
|
||||||
|
);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -81,17 +108,29 @@ export default function ProjectBoard() {
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(110,141,255,0.12),transparent_60%)] py-10">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(110,141,255,0.12),transparent_60%)] py-10">
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6">
|
<div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6">
|
||||||
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
|
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
|
||||||
<h1 className="text-3xl font-semibold text-foreground">Project Board</h1>
|
<h1 className="text-3xl font-semibold text-foreground">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Track tasks by status. Drag-and-drop coming next.</p>
|
Project Board
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Track tasks by status. Drag-and-drop coming next.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<Card key={col.key} className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
|
<Card
|
||||||
|
key={col.key}
|
||||||
|
className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg"
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
{col.title}
|
{col.title}
|
||||||
<Badge variant="outline" className="border-border/50 text-xs">{grouped[col.key].length}</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-border/50 text-xs"
|
||||||
|
>
|
||||||
|
{grouped[col.key].length}
|
||||||
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{col.hint}</CardDescription>
|
<CardDescription>{col.hint}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -100,14 +139,26 @@ export default function ProjectBoard() {
|
||||||
<p className="text-sm text-muted-foreground">No tasks.</p>
|
<p className="text-sm text-muted-foreground">No tasks.</p>
|
||||||
) : (
|
) : (
|
||||||
grouped[col.key].map((t) => (
|
grouped[col.key].map((t) => (
|
||||||
<div key={t.id} className="rounded-2xl border border-border/30 bg-background/60 p-3">
|
<div
|
||||||
<div className="font-medium text-foreground">{t.title}</div>
|
key={t.id}
|
||||||
|
className="rounded-2xl border border-border/30 bg-background/60 p-3"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{t.title}
|
||||||
|
</div>
|
||||||
{t.description ? (
|
{t.description ? (
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t.description}</p>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t.description}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{columns.map((k) => (
|
{columns.map((k) => (
|
||||||
<Button key={`${t.id}-${k.key}`} size="xs" variant="outline" onClick={() => move(t.id, k.key)}>
|
<Button
|
||||||
|
key={`${t.id}-${k.key}`}
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => move(t.id, k.key)}
|
||||||
|
>
|
||||||
{k.title}
|
{k.title}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -123,13 +174,27 @@ export default function ProjectBoard() {
|
||||||
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
|
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Add task</CardTitle>
|
<CardTitle className="text-lg">Add task</CardTitle>
|
||||||
<CardDescription>Keep titles concise; details optional.</CardDescription>
|
<CardDescription>
|
||||||
|
Keep titles concise; details optional.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Input placeholder="Task title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<Input
|
||||||
<Textarea placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
placeholder="Task title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleCreate} disabled={creating || !title.trim()} className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white">
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !title.trim()}
|
||||||
|
className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white"
|
||||||
|
>
|
||||||
{creating ? "Creating..." : "Create task"}
|
{creating ? "Creating..." : "Create task"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ import Layout from "@/components/Layout";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
@ -39,31 +45,57 @@ export default function Teams() {
|
||||||
load();
|
load();
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
const canCreate = useMemo(() => name.trim().length > 2 && !creating, [name, creating]);
|
const canCreate = useMemo(
|
||||||
|
() => name.trim().length > 2 && !creating,
|
||||||
|
[name, creating],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
if (!canCreate) return;
|
if (!canCreate) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
const optimistic = { team_id: tempId, teams: { id: tempId, name: name.trim(), description: description.trim() || null, visibility: "private" } } as any;
|
const optimistic = {
|
||||||
|
team_id: tempId,
|
||||||
|
teams: {
|
||||||
|
id: tempId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
visibility: "private",
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
setTeams((prev) => [optimistic, ...prev]);
|
setTeams((prev) => [optimistic, ...prev]);
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
let created: any | null = null;
|
let created: any | null = null;
|
||||||
try {
|
try {
|
||||||
created = await aethexCollabService.createTeam(user.id, optimistic.teams.name, optimistic.teams.description, "private");
|
created = await aethexCollabService.createTeam(
|
||||||
setTeams((prev) => prev.map((t: any) => (t.team_id === tempId ? { team_id: created.id, teams: created } : t)));
|
user.id,
|
||||||
|
optimistic.teams.name,
|
||||||
|
optimistic.teams.description,
|
||||||
|
"private",
|
||||||
|
);
|
||||||
|
setTeams((prev) =>
|
||||||
|
prev.map((t: any) =>
|
||||||
|
t.team_id === tempId ? { team_id: created.id, teams: created } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
aethexToast.success({ title: "Team created" });
|
aethexToast.success({ title: "Team created" });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setTeams((prev) => prev.filter((t: any) => t.team_id !== tempId));
|
setTeams((prev) => prev.filter((t: any) => t.team_id !== tempId));
|
||||||
aethexToast.error({ title: "Failed to create team", description: e?.message || "Try again later." });
|
aethexToast.error({
|
||||||
|
title: "Failed to create team",
|
||||||
|
description: e?.message || "Try again later.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading || isLoading) return <LoadingScreen message="Loading teams..." showProgress duration={800} />;
|
if (loading || isLoading)
|
||||||
|
return (
|
||||||
|
<LoadingScreen message="Loading teams..." showProgress duration={800} />
|
||||||
|
);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
@ -73,7 +105,9 @@ export default function Teams() {
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6">
|
<div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6">
|
||||||
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
|
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
|
||||||
<h1 className="text-3xl font-semibold text-foreground">Teams</h1>
|
<h1 className="text-3xl font-semibold text-foreground">Teams</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Create a team and collaborate with members across projects.</p>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Create a team and collaborate with members across projects.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
|
|
@ -85,25 +119,47 @@ export default function Teams() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{teams.length === 0 ? (
|
{teams.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No teams yet. Create one to get started.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No teams yet. Create one to get started.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
teams.map((t) => {
|
teams.map((t) => {
|
||||||
const team = (t as any).teams || t;
|
const team = (t as any).teams || t;
|
||||||
return (
|
return (
|
||||||
<div key={team.id} className="flex items-center justify-between rounded-2xl border border-border/30 bg-background/60 p-4">
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-border/30 bg-background/60 p-4"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarImage src={undefined} />
|
<AvatarImage src={undefined} />
|
||||||
<AvatarFallback>{team.name?.[0]?.toUpperCase() || "T"}</AvatarFallback>
|
<AvatarFallback>
|
||||||
|
{team.name?.[0]?.toUpperCase() || "T"}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">{team.name}</div>
|
<div className="font-medium text-foreground">
|
||||||
<div className="text-xs text-muted-foreground">{team.visibility || "private"}</div>
|
{team.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{team.visibility || "private"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="border-border/50">Team</Badge>
|
<Badge
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate(`/projects/new`)}>New project</Button>
|
variant="outline"
|
||||||
|
className="border-border/50"
|
||||||
|
>
|
||||||
|
Team
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/projects/new`)}
|
||||||
|
>
|
||||||
|
New project
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -117,13 +173,27 @@ export default function Teams() {
|
||||||
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
|
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Create a team</CardTitle>
|
<CardTitle className="text-lg">Create a team</CardTitle>
|
||||||
<CardDescription>Private by default; you can invite members later.</CardDescription>
|
<CardDescription>
|
||||||
|
Private by default; you can invite members later.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
|
<Input
|
||||||
<Textarea placeholder="Short description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
placeholder="Team name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Short description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleCreate} disabled={!canCreate} className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white">
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!canCreate}
|
||||||
|
className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white"
|
||||||
|
>
|
||||||
{creating ? "Creating..." : "Create team"}
|
{creating ? "Creating..." : "Create team"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,36 @@ export default function Terms() {
|
||||||
<div className="min-h-screen bg-aethex-gradient py-12">
|
<div className="min-h-screen bg-aethex-gradient py-12">
|
||||||
<div className="container mx-auto px-4 max-w-4xl space-y-8">
|
<div className="container mx-auto px-4 max-w-4xl space-y-8">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-3xl font-bold text-gradient-purple">Terms of Service</h1>
|
<h1 className="text-3xl font-bold text-gradient-purple">
|
||||||
<p className="text-sm text-muted-foreground">Effective date: 2025-10-18</p>
|
Terms of Service
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
These Terms govern your access to and use of the AeThex Services. By using the Services,
|
Effective date: 2025-10-18
|
||||||
you agree to these Terms.
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
These Terms govern your access to and use of the AeThex Services.
|
||||||
|
By using the Services, you agree to these Terms.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Accounts & Eligibility</h2>
|
<h2 className="font-semibold">Accounts & Eligibility</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You must be at least 13 years old (or the age required by your jurisdiction). You are
|
You must be at least 13 years old (or the age required by your
|
||||||
responsible for maintaining the confidentiality of your credentials and for all activity
|
jurisdiction). You are responsible for maintaining the
|
||||||
under your account.
|
confidentiality of your credentials and for all activity under
|
||||||
|
your account.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">User Content & License</h2>
|
<h2 className="font-semibold">User Content & License</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You retain ownership of content you submit. You grant AeThex a worldwide, non-exclusive,
|
You retain ownership of content you submit. You grant AeThex a
|
||||||
royalty-free license to host, store, reproduce, and display your content solely to operate
|
worldwide, non-exclusive, royalty-free license to host, store,
|
||||||
and improve the Services. You represent you have the rights to submit such content.
|
reproduce, and display your content solely to operate and improve
|
||||||
|
the Services. You represent you have the rights to submit such
|
||||||
|
content.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -38,31 +45,37 @@ export default function Terms() {
|
||||||
<li>No unlawful, infringing, or deceptive activity.</li>
|
<li>No unlawful, infringing, or deceptive activity.</li>
|
||||||
<li>No harassment, hate, or exploitation.</li>
|
<li>No harassment, hate, or exploitation.</li>
|
||||||
<li>No malware, spam, scraping, or abuse of API limits.</li>
|
<li>No malware, spam, scraping, or abuse of API limits.</li>
|
||||||
<li>No attempts to access others’ accounts or data without permission.</li>
|
<li>
|
||||||
|
No attempts to access others’ accounts or data without
|
||||||
|
permission.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Projects, Teams, and Matching</h2>
|
<h2 className="font-semibold">Projects, Teams, and Matching</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Collaboration features (projects, teams, tasks, endorsements) are provided as-is. Team owners
|
Collaboration features (projects, teams, tasks, endorsements) are
|
||||||
and project owners are responsible for membership, roles, and shared materials.
|
provided as-is. Team owners and project owners are responsible for
|
||||||
|
membership, roles, and shared materials.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Third-Party Services</h2>
|
<h2 className="font-semibold">Third-Party Services</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We may integrate third-party services (e.g., Supabase, Resend, hosting/CDN). Your use of
|
We may integrate third-party services (e.g., Supabase, Resend,
|
||||||
those services is subject to their terms and policies.
|
hosting/CDN). Your use of those services is subject to their terms
|
||||||
|
and policies.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Intellectual Property</h2>
|
<h2 className="font-semibold">Intellectual Property</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
AeThex and its licensors retain all rights to the platform, branding, and software. You may
|
AeThex and its licensors retain all rights to the platform,
|
||||||
not copy, modify, or distribute platform code or assets except as permitted by law or written
|
branding, and software. You may not copy, modify, or distribute
|
||||||
|
platform code or assets except as permitted by law or written
|
||||||
authorization.
|
authorization.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -70,16 +83,19 @@ export default function Terms() {
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Termination</h2>
|
<h2 className="font-semibold">Termination</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We may suspend or terminate your access for violations or risk to the platform. You may
|
We may suspend or terminate your access for violations or risk to
|
||||||
discontinue use at any time.
|
the platform. You may discontinue use at any time.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Disclaimers & Limitation of Liability</h2>
|
<h2 className="font-semibold">
|
||||||
|
Disclaimers & Limitation of Liability
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The Services are provided “as-is” without warranties. To the maximum extent permitted by law,
|
The Services are provided “as-is” without warranties. To the
|
||||||
AeThex will not be liable for indirect, incidental, or consequential damages arising from or
|
maximum extent permitted by law, AeThex will not be liable for
|
||||||
|
indirect, incidental, or consequential damages arising from or
|
||||||
related to your use of the Services.
|
related to your use of the Services.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -87,24 +103,25 @@ export default function Terms() {
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Indemnification</h2>
|
<h2 className="font-semibold">Indemnification</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You agree to indemnify and hold AeThex harmless from claims arising out of your content or
|
You agree to indemnify and hold AeThex harmless from claims
|
||||||
misuse of the Services.
|
arising out of your content or misuse of the Services.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Changes to Terms</h2>
|
<h2 className="font-semibold">Changes to Terms</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We may update these Terms. Material changes will be announced via the app or email. Your
|
We may update these Terms. Material changes will be announced via
|
||||||
continued use constitutes acceptance of updated Terms.
|
the app or email. Your continued use constitutes acceptance of
|
||||||
|
updated Terms.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="font-semibold">Governing Law & Contact</h2>
|
<h2 className="font-semibold">Governing Law & Contact</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
These Terms are governed by applicable laws of the United States. For questions, contact
|
These Terms are governed by applicable laws of the United States.
|
||||||
legal@aethex.biz.
|
For questions, contact legal@aethex.biz.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
117
server/index.ts
117
server/index.ts
|
|
@ -720,7 +720,9 @@ export function createServer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await accrue(inviter_id, "loyalty", 5, "invite_sent", { invitee: email });
|
await accrue(inviter_id, "loyalty", 5, "invite_sent", {
|
||||||
|
invitee: email,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await adminSupabase.from("notifications").insert({
|
await adminSupabase.from("notifications").insert({
|
||||||
user_id: inviter_id,
|
user_id: inviter_id,
|
||||||
|
|
@ -799,7 +801,9 @@ export function createServer() {
|
||||||
if (inviterId) {
|
if (inviterId) {
|
||||||
await accrue(inviterId, "xp", 100, "invite_accepted", { token });
|
await accrue(inviterId, "xp", 100, "invite_accepted", { token });
|
||||||
await accrue(inviterId, "loyalty", 50, "invite_accepted", { token });
|
await accrue(inviterId, "loyalty", 50, "invite_accepted", { token });
|
||||||
await accrue(inviterId, "reputation", 2, "invite_accepted", { token });
|
await accrue(inviterId, "reputation", 2, "invite_accepted", {
|
||||||
|
token,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await adminSupabase.from("notifications").insert({
|
await adminSupabase.from("notifications").insert({
|
||||||
user_id: inviterId,
|
user_id: inviterId,
|
||||||
|
|
@ -810,7 +814,9 @@ export function createServer() {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
await accrue(acceptor_id, "xp", 50, "invite_accepted", { token });
|
await accrue(acceptor_id, "xp", 50, "invite_accepted", { token });
|
||||||
await accrue(acceptor_id, "reputation", 1, "invite_accepted", { token });
|
await accrue(acceptor_id, "reputation", 1, "invite_accepted", {
|
||||||
|
token,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await adminSupabase.from("notifications").insert({
|
await adminSupabase.from("notifications").insert({
|
||||||
user_id: acceptor_id,
|
user_id: acceptor_id,
|
||||||
|
|
@ -828,20 +834,32 @@ export function createServer() {
|
||||||
|
|
||||||
// Follow/unfollow with notifications
|
// Follow/unfollow with notifications
|
||||||
app.post("/api/social/follow", async (req, res) => {
|
app.post("/api/social/follow", async (req, res) => {
|
||||||
const { follower_id, following_id } = (req.body || {}) as { follower_id?: string; following_id?: string };
|
const { follower_id, following_id } = (req.body || {}) as {
|
||||||
|
follower_id?: string;
|
||||||
|
following_id?: string;
|
||||||
|
};
|
||||||
if (!follower_id || !following_id)
|
if (!follower_id || !following_id)
|
||||||
return res.status(400).json({ error: "follower_id and following_id required" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "follower_id and following_id required" });
|
||||||
try {
|
try {
|
||||||
await adminSupabase
|
await adminSupabase
|
||||||
.from("user_follows")
|
.from("user_follows")
|
||||||
.upsert({ follower_id, following_id } as any, { onConflict: "follower_id,following_id" as any });
|
.upsert({ follower_id, following_id } as any, {
|
||||||
await accrue(follower_id, "loyalty", 5, "follow_user", { following_id });
|
onConflict: "follower_id,following_id" as any,
|
||||||
|
});
|
||||||
|
await accrue(follower_id, "loyalty", 5, "follow_user", {
|
||||||
|
following_id,
|
||||||
|
});
|
||||||
const { data: follower } = await adminSupabase
|
const { data: follower } = await adminSupabase
|
||||||
.from("user_profiles")
|
.from("user_profiles")
|
||||||
.select("full_name, username")
|
.select("full_name, username")
|
||||||
.eq("id", follower_id)
|
.eq("id", follower_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
const followerName = (follower as any)?.full_name || (follower as any)?.username || "Someone";
|
const followerName =
|
||||||
|
(follower as any)?.full_name ||
|
||||||
|
(follower as any)?.username ||
|
||||||
|
"Someone";
|
||||||
await adminSupabase.from("notifications").insert({
|
await adminSupabase.from("notifications").insert({
|
||||||
user_id: following_id,
|
user_id: following_id,
|
||||||
type: "info",
|
type: "info",
|
||||||
|
|
@ -855,9 +873,14 @@ export function createServer() {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/social/unfollow", async (req, res) => {
|
app.post("/api/social/unfollow", async (req, res) => {
|
||||||
const { follower_id, following_id } = (req.body || {}) as { follower_id?: string; following_id?: string };
|
const { follower_id, following_id } = (req.body || {}) as {
|
||||||
|
follower_id?: string;
|
||||||
|
following_id?: string;
|
||||||
|
};
|
||||||
if (!follower_id || !following_id)
|
if (!follower_id || !following_id)
|
||||||
return res.status(400).json({ error: "follower_id and following_id required" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "follower_id and following_id required" });
|
||||||
try {
|
try {
|
||||||
await adminSupabase
|
await adminSupabase
|
||||||
.from("user_follows")
|
.from("user_follows")
|
||||||
|
|
@ -872,18 +895,32 @@ export function createServer() {
|
||||||
|
|
||||||
// Endorse with notification
|
// Endorse with notification
|
||||||
app.post("/api/social/endorse", async (req, res) => {
|
app.post("/api/social/endorse", async (req, res) => {
|
||||||
const { endorser_id, endorsed_id, skill } = (req.body || {}) as { endorser_id?: string; endorsed_id?: string; skill?: string };
|
const { endorser_id, endorsed_id, skill } = (req.body || {}) as {
|
||||||
|
endorser_id?: string;
|
||||||
|
endorsed_id?: string;
|
||||||
|
skill?: string;
|
||||||
|
};
|
||||||
if (!endorser_id || !endorsed_id || !skill)
|
if (!endorser_id || !endorsed_id || !skill)
|
||||||
return res.status(400).json({ error: "endorser_id, endorsed_id, skill required" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "endorser_id, endorsed_id, skill required" });
|
||||||
try {
|
try {
|
||||||
await adminSupabase.from("endorsements").insert({ endorser_id, endorsed_id, skill } as any);
|
await adminSupabase
|
||||||
await accrue(endorsed_id, "reputation", 2, "endorsement_received", { skill, from: endorser_id });
|
.from("endorsements")
|
||||||
|
.insert({ endorser_id, endorsed_id, skill } as any);
|
||||||
|
await accrue(endorsed_id, "reputation", 2, "endorsement_received", {
|
||||||
|
skill,
|
||||||
|
from: endorser_id,
|
||||||
|
});
|
||||||
const { data: endorser } = await adminSupabase
|
const { data: endorser } = await adminSupabase
|
||||||
.from("user_profiles")
|
.from("user_profiles")
|
||||||
.select("full_name, username")
|
.select("full_name, username")
|
||||||
.eq("id", endorser_id)
|
.eq("id", endorser_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
const endorserName = (endorser as any)?.full_name || (endorser as any)?.username || "Someone";
|
const endorserName =
|
||||||
|
(endorser as any)?.full_name ||
|
||||||
|
(endorser as any)?.username ||
|
||||||
|
"Someone";
|
||||||
await adminSupabase.from("notifications").insert({
|
await adminSupabase.from("notifications").insert({
|
||||||
user_id: endorsed_id,
|
user_id: endorsed_id,
|
||||||
type: "success",
|
type: "success",
|
||||||
|
|
@ -909,24 +946,48 @@ export function createServer() {
|
||||||
metadata,
|
metadata,
|
||||||
} = (req.body || {}) as any;
|
} = (req.body || {}) as any;
|
||||||
if (!actor_id || !verb || !object_type) {
|
if (!actor_id || !verb || !object_type) {
|
||||||
return res.status(400).json({ error: "actor_id, verb, object_type required" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "actor_id, verb, object_type required" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { data: eventRow, error: evErr } = await adminSupabase
|
const { data: eventRow, error: evErr } = await adminSupabase
|
||||||
.from("activity_events")
|
.from("activity_events")
|
||||||
.insert({ actor_id, verb, object_type, object_id: object_id || null, target_id: target_team_id || target_project_id || null, metadata: metadata || null } as any)
|
.insert({
|
||||||
|
actor_id,
|
||||||
|
verb,
|
||||||
|
object_type,
|
||||||
|
object_id: object_id || null,
|
||||||
|
target_id: target_team_id || target_project_id || null,
|
||||||
|
metadata: metadata || null,
|
||||||
|
} as any)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (evErr) return res.status(500).json({ error: evErr.message });
|
if (evErr) return res.status(500).json({ error: evErr.message });
|
||||||
|
|
||||||
const notify = async (userId: string, title: string, message?: string) => {
|
const notify = async (
|
||||||
await adminSupabase.from("notifications").insert({ user_id: userId, type: "info", title, message: message || null });
|
userId: string,
|
||||||
|
title: string,
|
||||||
|
message?: string,
|
||||||
|
) => {
|
||||||
|
await adminSupabase
|
||||||
|
.from("notifications")
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
type: "info",
|
||||||
|
title,
|
||||||
|
message: message || null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notify explicit targets
|
// Notify explicit targets
|
||||||
if (Array.isArray(target_user_ids) && target_user_ids.length) {
|
if (Array.isArray(target_user_ids) && target_user_ids.length) {
|
||||||
for (const uid of target_user_ids) {
|
for (const uid of target_user_ids) {
|
||||||
await notify(uid, `${verb} · ${object_type}`, (metadata && metadata.summary) || null);
|
await notify(
|
||||||
|
uid,
|
||||||
|
`${verb} · ${object_type}`,
|
||||||
|
(metadata && metadata.summary) || null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,8 +997,12 @@ export function createServer() {
|
||||||
.from("team_memberships")
|
.from("team_memberships")
|
||||||
.select("user_id")
|
.select("user_id")
|
||||||
.eq("team_id", target_team_id);
|
.eq("team_id", target_team_id);
|
||||||
for (const m of (members || [])) {
|
for (const m of members || []) {
|
||||||
await notify((m as any).user_id, `${verb} · ${object_type}`, (metadata && metadata.summary) || null);
|
await notify(
|
||||||
|
(m as any).user_id,
|
||||||
|
`${verb} · ${object_type}`,
|
||||||
|
(metadata && metadata.summary) || null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -947,8 +1012,12 @@ export function createServer() {
|
||||||
.from("project_members")
|
.from("project_members")
|
||||||
.select("user_id")
|
.select("user_id")
|
||||||
.eq("project_id", target_project_id);
|
.eq("project_id", target_project_id);
|
||||||
for (const m of (members || [])) {
|
for (const m of members || []) {
|
||||||
await notify((m as any).user_id, `${verb} · ${object_type}`, (metadata && metadata.summary) || null);
|
await notify(
|
||||||
|
(m as any).user_id,
|
||||||
|
`${verb} · ${object_type}`,
|
||||||
|
(metadata && metadata.summary) || null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue