feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs
Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add requireWorkspaceAccess + recordActivityForEntity conventions. Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD + bulk save + debounced autosave; logout route; graph edge workspaceId derivation; real analytics endpoints (drop Math.random); task board droppable columns + reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/ export/realtime scoping; command palette routing + agent mention fetch; agent activity SSE handler; graph fly-to with tracked positions. Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared Loading/Empty/Error state components; working density/sidebarPos/reduce-motion settings; Inter typography; consolidated status-colors lib; unified detail routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y (radiogroups, sanitized snippets, badge labels). Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy create; active-domain store + topbar picker; graph domain picker + navigable entity links; tag assign/remove UI + server-side tag filter; real CSV export + import validation; custom fields on tasks. Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents CRUD + permission editing + activity filters; real notifications feed; MCP polish (validation, error codes, domain scoping, dead sql leftover). Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite docs/API.md for the real Hono API.
This commit is contained in:
@@ -64,6 +64,7 @@
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"recharts": "^3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3",
|
||||
@@ -73,6 +74,7 @@
|
||||
"@tanstack/react-query-devtools": "^5.62.0",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.5.2",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { CustomField } from "@/lib/types";
|
||||
|
||||
export type CustomFieldsValue = Record<string, unknown>;
|
||||
|
||||
interface CustomFieldInputsProps {
|
||||
/**
|
||||
* Plural entity type used by the custom-fields API, e.g. "tasks" | "habits".
|
||||
*/
|
||||
entityType: string;
|
||||
values: CustomFieldsValue;
|
||||
onChange: (values: CustomFieldsValue) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a type-aware input for every custom field defined for an entity.
|
||||
* The entity payload carries values as `customFields: { [fieldName]: value }`,
|
||||
* which this component reads and writes via `values` / `onChange`.
|
||||
*
|
||||
* Renders nothing when no custom fields are defined for the entity.
|
||||
*/
|
||||
export function CustomFieldInputs({ entityType, values, onChange }: CustomFieldInputsProps) {
|
||||
const activeDomainId = useApiDomain();
|
||||
const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>(
|
||||
["custom-fields", entityType, activeDomainId],
|
||||
"/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
const fields = data?.items ?? [];
|
||||
|
||||
// Keep the latest values in a ref so the default-seeding effect below never
|
||||
// clobbers edits the user makes while definitions refetch in the background.
|
||||
const valuesRef = useRef(values);
|
||||
valuesRef.current = values;
|
||||
|
||||
// Seed defaults for fields that have no value yet (e.g. a field created after
|
||||
// the task already existed, or a brand new task with field defaults).
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) return;
|
||||
let changed = false;
|
||||
const next: CustomFieldsValue = { ...valuesRef.current };
|
||||
for (const field of fields) {
|
||||
if (next[field.name] === undefined && field.defaultValue !== null && field.defaultValue !== undefined) {
|
||||
next[field.name] = field.defaultValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) onChange(next);
|
||||
// `valuesRef.current` is intentionally read (not listed) so the effect only
|
||||
// runs when the field definitions change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fields, onChange]);
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-muted-foreground">Custom Fields</Label>
|
||||
<div className="mt-3 space-y-4">
|
||||
{fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.name]}
|
||||
onValueChange={(name, value) => onChange({ ...values, [name]: value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
field: CustomField;
|
||||
value: unknown;
|
||||
onValueChange: (name: string, value: unknown) => void;
|
||||
}) {
|
||||
const inputId = "cf-" + field.name;
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id={inputId} checked={Boolean(value)} onCheckedChange={(v) => onValueChange(field.name, v === true)} />
|
||||
<Label htmlFor={inputId} className="font-normal">{field.name}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "multi_select") {
|
||||
const selected: string[] = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{field.name}</Label>
|
||||
<div className="space-y-1.5">
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<div key={opt} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={inputId + "-" + opt}
|
||||
checked={selected.includes(opt)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...selected, opt] : selected.filter((o) => o !== opt);
|
||||
onValueChange(field.name, next.length > 0 ? next : null);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={inputId + "-" + opt} className="font-normal">{opt}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={inputId}>
|
||||
{field.name}
|
||||
{field.required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{renderStandardControl(field, value, inputId, onValueChange)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStandardControl(
|
||||
field: CustomField,
|
||||
value: unknown,
|
||||
inputId: string,
|
||||
onValueChange: (name: string, value: unknown) => void
|
||||
) {
|
||||
switch (field.type) {
|
||||
case "number": {
|
||||
let numeric: number | "" = "";
|
||||
if (typeof value === "number") numeric = value;
|
||||
else if (value !== undefined && value !== null) {
|
||||
const parsed = Number(value);
|
||||
numeric = Number.isNaN(parsed) ? "" : parsed;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="number"
|
||||
value={numeric}
|
||||
onChange={(e) => onValueChange(field.name, e.target.value === "" ? null : Number(e.target.value))}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "date": {
|
||||
return (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="date"
|
||||
value={value !== undefined && value !== null ? String(value).slice(0, 10) : ""}
|
||||
onChange={(e) => onValueChange(field.name, e.target.value || null)}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "select": {
|
||||
return (
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(v) => onValueChange(field.name, v || null)}
|
||||
>
|
||||
<SelectTrigger id={inputId}><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={value !== undefined && value !== null ? String(value) : ""}
|
||||
onChange={(e) => onValueChange(field.name, e.target.value || null)}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { CustomField } from "@/lib/types";
|
||||
|
||||
interface CustomFieldsDisplayProps {
|
||||
/**
|
||||
* Plural entity type used by the custom-fields API, e.g. "tasks" | "habits".
|
||||
*/
|
||||
entityType: string;
|
||||
values?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only list of an entity's custom field values. Uses the field
|
||||
* definitions for labels and type-aware formatting when they are available;
|
||||
* falls back to raw `name: value` rendering otherwise. Skips empty values and
|
||||
* renders nothing when there is nothing to show.
|
||||
*/
|
||||
export function CustomFieldsDisplay({ entityType, values }: CustomFieldsDisplayProps) {
|
||||
const activeDomainId = useApiDomain();
|
||||
const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>(
|
||||
["custom-fields", entityType, activeDomainId],
|
||||
"/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
if (!values) return null;
|
||||
|
||||
const defs = new Map((data?.items ?? []).map((f) => [f.name, f]));
|
||||
const entries = Object.entries(values).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Custom Fields</h3>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([name, value]) => {
|
||||
const field = defs.get(name);
|
||||
return (
|
||||
<div key={name} className="flex items-start gap-2 text-sm">
|
||||
<span className="w-40 shrink-0 text-muted-foreground">{field?.name ?? name}</span>
|
||||
<span className="flex-1 min-w-0 break-words">{formatValue(field, value)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(field: CustomField | undefined, value: unknown): string {
|
||||
if (field) {
|
||||
if (field.type === "boolean") return value ? "Yes" : "No";
|
||||
if (field.type === "date" && typeof value === "string") {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? value.slice(0, 10) : d.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { X } from "lucide-react";
|
||||
import type { Tag, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const ENTITY_ROUTES: Record<"task" | "habit" | "note", string> = {
|
||||
task: "tasks",
|
||||
habit: "habits",
|
||||
note: "notes",
|
||||
};
|
||||
|
||||
interface TagManagerProps {
|
||||
entityType: "task" | "habit" | "note";
|
||||
entityId: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign/remove tags on an entity from its detail page. The entity's tags come
|
||||
* from the parent's query data; mutations hit the tag junction endpoints and
|
||||
* refetch the entity so badges stay in sync with the API.
|
||||
*/
|
||||
export function TagManager({ entityType, entityId, tags }: TagManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [addValue, setAddValue] = useState("");
|
||||
const plural = ENTITY_ROUTES[entityType];
|
||||
|
||||
// Always fetch fresh so tags created elsewhere show up in the add dropdown.
|
||||
const { data: tagsData } = useApiQuery<PaginatedResponse<Tag>>(
|
||||
["tags"],
|
||||
"/tags?perPage=100&sort=name",
|
||||
{ staleTime: 0 }
|
||||
);
|
||||
const allTags = tagsData?.items || [];
|
||||
|
||||
const assignedIds = new Set(tags.map((t) => t.id));
|
||||
const availableTags = allTags.filter((t) => !assignedIds.has(t.id));
|
||||
|
||||
const refreshEntity = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [entityType, entityId] });
|
||||
};
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (tagId: string) => api.post(`/${plural}/${entityId}/tags`, { tagId }),
|
||||
onSuccess: refreshEntity,
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (tagId: string) => api.delete(`/${plural}/${entityId}/tags/${tagId}`),
|
||||
onSuccess: refreshEntity,
|
||||
});
|
||||
|
||||
const handleAssign = (tagId: string) => {
|
||||
if (!tagId || assignedIds.has(tagId)) return;
|
||||
setAddValue("");
|
||||
assignMutation.mutate(tagId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{tags.length === 0 && (
|
||||
<span className="text-sm text-muted-foreground">No tags</span>
|
||||
)}
|
||||
{tags.map((t) => (
|
||||
<Badge key={t.id} variant="secondary" style={t.color ? { borderColor: t.color } : undefined}>
|
||||
{t.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(t.id)}
|
||||
disabled={removeMutation.isPending}
|
||||
aria-label={"Remove tag " + t.name}
|
||||
className="ml-1.5 rounded-full p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Select value={addValue} onValueChange={handleAssign} disabled={availableTags.length === 0}>
|
||||
<SelectTrigger className="h-8 w-64 text-xs" aria-label="Add tag">
|
||||
<SelectValue placeholder={availableTags.length === 0 ? "No more tags to add" : "Add tag..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTags.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,9 +73,9 @@ export function CommandPalette() {
|
||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{ type: string; items: Array<{ id: string; title: string }> }>
|
||||
Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
|
||||
>([]);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -178,7 +178,7 @@ export function CommandPalette() {
|
||||
setSearchResults([
|
||||
{
|
||||
type: "Agents",
|
||||
items: (data.agents || data.results || []).map((a: { id: string; name: string }) => ({
|
||||
items: (data.items || []).map((a: { id: string; name: string }) => ({
|
||||
id: a.id,
|
||||
title: a.name,
|
||||
})),
|
||||
@@ -311,17 +311,17 @@ export function CommandPalette() {
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
const typeRoute =
|
||||
group.type === "tasks"
|
||||
? "/tasks"
|
||||
: group.type === "habits"
|
||||
? "/habits"
|
||||
: group.type === "projects"
|
||||
? "/projects"
|
||||
: group.type === "notes"
|
||||
? "/notes"
|
||||
: "/search";
|
||||
runCommand(() => navigate({ to: `${typeRoute}/${item.id}` }));
|
||||
// The search API returns singular types ("task", "note", ...)
|
||||
// and each result carries a ready-made detail link (e.g.
|
||||
// "/tasks/{id}"). Domains have no detail route, so land on the
|
||||
// dashboard (the domain-scoped home). Agent mentions have no
|
||||
// detail page either, so just dismiss the palette.
|
||||
if (group.type === "Agents") {
|
||||
runCommand(() => {});
|
||||
return;
|
||||
}
|
||||
const link = group.type === "domain" ? "/" : item.link!;
|
||||
runCommand(() => navigate({ to: link }));
|
||||
}}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useActiveDomainStore } from "@/lib/stores/use-active-domain-store";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Domain, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
export function DomainPicker() {
|
||||
const activeDomainId = useActiveDomainStore((s) => s.activeDomainId);
|
||||
const setActiveDomain = useActiveDomainStore((s) => s.setActiveDomain);
|
||||
|
||||
const { data, isLoading } = useApiQuery<PaginatedResponse<Domain>>(["domains"], "/domains");
|
||||
const domains = data?.items || [];
|
||||
|
||||
// The persisted selection may reference a deleted domain — validate against
|
||||
// the fetched list and fall back to the first domain while unset or stale.
|
||||
useEffect(() => {
|
||||
if (domains.length === 0) return;
|
||||
if (!activeDomainId || !domains.some((d) => d.id === activeDomainId)) {
|
||||
setActiveDomain(domains[0].id);
|
||||
}
|
||||
}, [domains, activeDomainId, setActiveDomain]);
|
||||
|
||||
const empty = isLoading || domains.length === 0;
|
||||
|
||||
return (
|
||||
<div className="hidden md:block">
|
||||
<Select
|
||||
value={activeDomainId ?? undefined}
|
||||
onValueChange={setActiveDomain}
|
||||
disabled={empty}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-40 text-xs" aria-label="Switch domain">
|
||||
<SelectValue placeholder="Domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map((domain) => (
|
||||
<SelectItem key={domain.id} value={domain.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
{domain.color && (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: domain.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{domain.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||
@@ -76,6 +77,24 @@ export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
|
||||
|
||||
// Sidebar position (left/right) is set in Settings. Read once on mount and
|
||||
// update live via the "sidebar-position-change" custom event dispatched by
|
||||
// the settings page.
|
||||
const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
|
||||
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
|
||||
? "right"
|
||||
: "left"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onSidebarPositionChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<string>).detail;
|
||||
if (detail === "left" || detail === "right") setSidebarPos(detail);
|
||||
};
|
||||
window.addEventListener("sidebar-position-change", onSidebarPositionChange);
|
||||
return () => window.removeEventListener("sidebar-position-change", onSidebarPositionChange);
|
||||
}, []);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/") return location.pathname === "/";
|
||||
return location.pathname.startsWith(href);
|
||||
@@ -114,7 +133,7 @@ export function Sidebar() {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
<TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -143,8 +162,9 @@ export function Sidebar() {
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden flex-col border-r bg-card transition-all duration-200 md:flex",
|
||||
collapsed ? "w-16" : "w-60"
|
||||
"hidden flex-col bg-card transition-all duration-200 md:flex",
|
||||
collapsed ? "w-16" : "w-60",
|
||||
sidebarPos === "right" ? "order-last border-l" : "border-r"
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
@@ -188,7 +208,7 @@ export function Sidebar() {
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" className="w-48">
|
||||
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => {}}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
@@ -214,7 +234,7 @@ export function Sidebar() {
|
||||
<span className="text-sm font-medium">User</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" className="w-48">
|
||||
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => {}}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
@@ -236,7 +256,7 @@ export function Sidebar() {
|
||||
|
||||
{/* Mobile sheet */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col p-0 md:hidden">
|
||||
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 md:hidden">
|
||||
<SheetHeader className="border-b px-4 py-4 pr-12">
|
||||
<SheetTitle>Project E</SheetTitle>
|
||||
<SheetDescription>Navigate your workspace.</SheetDescription>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Search, Bell, Plus, Menu } from "lucide-react";
|
||||
import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DomainPicker } from "@/components/shell/domain-picker";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -15,14 +19,45 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { NotificationsResponse } from "@/lib/types";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
deleted: "deleted",
|
||||
completed: "completed",
|
||||
};
|
||||
|
||||
function readableEntityType(entityType: string): string {
|
||||
return entityType
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const { setMobileOpen } = useSidebarStore();
|
||||
const queryClient = useQueryClient();
|
||||
const domainId = useApiDomain();
|
||||
|
||||
const openPalette = () => {
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
};
|
||||
|
||||
const { data: notificationsData } = useApiQuery<NotificationsResponse>(
|
||||
["notifications", domainId],
|
||||
"/notifications?workspace_id=" + encodeURIComponent(domainId),
|
||||
{ enabled: !!domainId, refetchInterval: 60_000 }
|
||||
);
|
||||
|
||||
const notifications = notificationsData?.items || [];
|
||||
const count = notificationsData?.count || 0;
|
||||
const badgeLabel = count > 99 ? "99+" : String(count);
|
||||
const tooltipText =
|
||||
count === 0
|
||||
? "No notifications"
|
||||
: `${count} unread notification${count === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
||||
@@ -55,6 +90,9 @@ export function Topbar() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Domain picker */}
|
||||
<DomainPicker />
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Quick add */}
|
||||
@@ -76,17 +114,57 @@ export function Topbar() {
|
||||
|
||||
{/* Notifications bell */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
|
||||
0
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>No notifications</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
||||
<Bell className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-sm font-medium">Notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["notifications"] })}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex cursor-default flex-col items-start gap-0.5 py-2">
|
||||
<span className="text-sm capitalize">
|
||||
{readableEntityType(n.entityType)}{" "}
|
||||
{ACTION_LABELS[n.action] || n.action}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{n.actor} · {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* User avatar */}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2, Inbox, AlertCircle, RotateCw, type LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Centered loading state with a spinner and optional label.
|
||||
* Drop-in replacement for inline `<div className="py-12 text-center">Loading...</div>` markup.
|
||||
*/
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
{label ? <p className="text-sm">{label}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered empty state: icon, title, optional description and optional CTA.
|
||||
* `action` is rendered below the text (e.g. a Button that opens a create dialog).
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="pt-1">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered error state with a destructive-styled message and an optional
|
||||
* retry button that re-runs the failed query.
|
||||
*/
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">{message}</p>
|
||||
{onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
const { workspaceId, enabled = true } = options;
|
||||
const queryClient = useQueryClient();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
@@ -23,19 +23,22 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
|
||||
switch (entityType) {
|
||||
case "task":
|
||||
queryKeys.push(["tasks"]);
|
||||
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]);
|
||||
break;
|
||||
case "habit":
|
||||
queryKeys.push(["habits"]);
|
||||
queryKeys.push(["habits"], ["habits-today"], ["streaks"]);
|
||||
break;
|
||||
case "project":
|
||||
queryKeys.push(["projects"]);
|
||||
queryKeys.push(["projects"], ["active-projects"]);
|
||||
break;
|
||||
case "note":
|
||||
queryKeys.push(["notes"]);
|
||||
queryKeys.push(["notes"], ["recent-notes"]);
|
||||
break;
|
||||
case "calendar_event":
|
||||
queryKeys.push(["calendar-events"]);
|
||||
queryKeys.push(["calendar-events"], ["upcoming-events"]);
|
||||
break;
|
||||
case "dashboard_widget":
|
||||
queryKeys.push(["dashboard-widgets"]);
|
||||
break;
|
||||
case "graph_edge":
|
||||
queryKeys.push(["graph"]);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Typography — Inter via Google Fonts @import above; falls back to system-ui */
|
||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* Density — scales content spacing (see the density utilities below and the
|
||||
calc() padding on <main> in the app layout). 1 = comfortable. */
|
||||
--density-scale: 1;
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
@@ -27,6 +36,17 @@
|
||||
--accent-hsl: 217 91% 60%;
|
||||
}
|
||||
|
||||
/* Density modes: toggled on <html> by the settings page (and applied from
|
||||
localStorage on app load). They only flip --density-scale, which the
|
||||
layout padding and the space-y utilities below multiply by. */
|
||||
.density-compact {
|
||||
--density-scale: 0.85;
|
||||
}
|
||||
|
||||
.density-spacious {
|
||||
--density-scale: 1.15;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
@@ -57,5 +77,55 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
}
|
||||
|
||||
/* Real reduced-motion support: the settings page toggles `.reduce-motion` on
|
||||
<html>. This standard override collapses animation/transition durations so
|
||||
users who enable it get an effectively static UI. */
|
||||
.reduce-motion *,
|
||||
.reduce-motion *::before,
|
||||
.reduce-motion *::after {
|
||||
animation-duration: 0.001s !important;
|
||||
transition-duration: 0.001s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Density: multiply the vertical rhythm between stacked sections inside page
|
||||
content. Mirrors Tailwind's own `.space-y-*` selector shape and is scoped
|
||||
to <main> so the sidebar and topbar stay fixed. Combined with the calc()
|
||||
padding on <main> in the app layout, the Density setting now visibly
|
||||
changes spacing (0.85x compact, 1.15x spacious). */
|
||||
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(0.25rem * var(--density-scale));
|
||||
}
|
||||
.density-compact main .space-y-2 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(0.5rem * var(--density-scale));
|
||||
}
|
||||
.density-compact main .space-y-3 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-3 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(0.75rem * var(--density-scale));
|
||||
}
|
||||
.density-compact main .space-y-4 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(1rem * var(--density-scale));
|
||||
}
|
||||
.density-compact main .space-y-5 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-5 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(1.25rem * var(--density-scale));
|
||||
}
|
||||
.density-compact main .space-y-6 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-6 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(1.5rem * var(--density-scale));
|
||||
}
|
||||
.density-compact main .space-y-8 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(2rem * var(--density-scale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ async function apiFetch<T>(
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// Some routes return 204 No Content (e.g. deletes). res.json() throws on an
|
||||
// empty body, so return undefined for those instead.
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Shared semantic color tokens for entities (status, priority, type).
|
||||
*
|
||||
* Single source of truth — pages must import these instead of defining local
|
||||
* color maps. All values are Tailwind classes unless the comment says otherwise.
|
||||
*/
|
||||
|
||||
export interface StatusToken {
|
||||
label: string;
|
||||
/** Tailwind class for a small colored dot (e.g. `w-2 h-2 rounded-full`). */
|
||||
dot: string;
|
||||
/** Tailwind classes for a filled Badge (bg + text). */
|
||||
badge: string;
|
||||
}
|
||||
|
||||
/** Task workflow statuses (board column dots + badges). */
|
||||
export const TASK_STATUS: Record<string, StatusToken> = {
|
||||
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
||||
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
||||
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
||||
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
|
||||
};
|
||||
|
||||
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
||||
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" },
|
||||
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" },
|
||||
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" },
|
||||
};
|
||||
|
||||
/** Project lifecycle statuses. */
|
||||
export const PROJECT_STATUS: Record<string, StatusToken> = {
|
||||
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
||||
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
|
||||
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
||||
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Graph node colors by entity type (hex).
|
||||
*
|
||||
* These are consumed by the canvas renderer (`ctx.fillStyle`) and inline
|
||||
* `style={{ backgroundColor }}` props. CSS variables don't work in canvas
|
||||
* `fillStyle`, so keep literal hex values here.
|
||||
*/
|
||||
export const ENTITY: Record<string, string> = {
|
||||
task: "#3b82f6",
|
||||
habit: "#10b981",
|
||||
project: "#8b5cf6",
|
||||
note: "#f59e0b",
|
||||
section: "#ec4899",
|
||||
tag: "#6b7280",
|
||||
domain: "#6366f1",
|
||||
};
|
||||
|
||||
/**
|
||||
* Calendar event colors by entity type.
|
||||
*
|
||||
* Values are HSL triplets consumed as `hsl(${hue})` / `hsla(${hue}, 0.15)`.
|
||||
* `task` resolves through the theme accent (`var(--accent-hsl)`), so events
|
||||
* pick up the user's chosen accent color.
|
||||
*/
|
||||
export const CALENDAR_EVENT: Record<string, string> = {
|
||||
task: "var(--accent-hsl, 217 91% 60%)",
|
||||
habit: "142 71% 45%",
|
||||
project: "271 81% 56%",
|
||||
note: "24 95% 53%",
|
||||
default: "215 16% 47%",
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
|
||||
interface ActiveDomainState {
|
||||
activeDomainId: string | null;
|
||||
setActiveDomain: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useActiveDomainStore = create<ActiveDomainState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeDomainId: null,
|
||||
setActiveDomain: (id) => set({ activeDomainId: id }),
|
||||
}),
|
||||
{
|
||||
name: "project-e-active-domain",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Raw selector: reads the persisted value (may reference a deleted domain).
|
||||
export function useActiveDomainId(): string | null {
|
||||
return useActiveDomainStore((s) => s.activeDomainId);
|
||||
}
|
||||
|
||||
// Resolved active domain id for API calls. Validates the persisted value
|
||||
// against the user's domains (falling back to the first domain) so consumers
|
||||
// never query a domain that no longer exists. Returns "" until domains load.
|
||||
// Shares the ["domains"] query cache, so it adds no extra network requests.
|
||||
export function useApiDomain(): string {
|
||||
const activeDomainId = useActiveDomainId();
|
||||
const { data } = useApiQuery<{ items: { id: string }[] }>(["domains"], "/domains");
|
||||
const items = data?.items || [];
|
||||
if (items.length === 0) return "";
|
||||
if (activeDomainId && items.some((d) => d.id === activeDomainId)) return activeDomainId;
|
||||
return items[0].id;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface Task {
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
tags: Tag[];
|
||||
customFields?: Record<string, unknown>;
|
||||
subtasks?: Task[];
|
||||
dependencies?: { id: string; title: string; status: string }[];
|
||||
dependents?: { id: string; title: string; status: string }[];
|
||||
@@ -133,6 +134,8 @@ export interface GraphNode {
|
||||
label: string;
|
||||
type: string;
|
||||
color: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
@@ -259,10 +262,27 @@ export interface Agent {
|
||||
domainId: string;
|
||||
tags: string[];
|
||||
config: Record<string, unknown>;
|
||||
apiKey?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
changes: Record<string, unknown> | null;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationsResponse {
|
||||
items: Notification[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
id: string;
|
||||
agentId: string;
|
||||
@@ -343,10 +363,28 @@ export interface HabitAnalytics {
|
||||
period: number;
|
||||
}
|
||||
|
||||
export interface ProjectAnalytics {
|
||||
taskCompletionRate: number;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
export interface DailyAnalyticsItem {
|
||||
date: string;
|
||||
created: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface DailyAnalytics {
|
||||
items: DailyAnalyticsItem[];
|
||||
period: number;
|
||||
}
|
||||
|
||||
export interface ProjectProgress {
|
||||
id: string;
|
||||
name: string;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface ProjectAnalytics {
|
||||
projects: ProjectProgress[];
|
||||
totalProjects: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
{import.meta.env.DEV && <TanStackRouterDevtools />}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { createRoute, Outlet } from "@tanstack/react-router";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
import { Sidebar } from "@/components/shell/sidebar";
|
||||
@@ -9,6 +10,18 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
function AppLayout() {
|
||||
useKeyboardShortcuts();
|
||||
|
||||
// Apply persisted appearance preferences (density, reduced motion) right
|
||||
// after the first paint. The settings page updates these live while open;
|
||||
// this covers reloads where the settings page was never visited.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("density-compact", "density-spacious");
|
||||
const density = localStorage.getItem("density");
|
||||
if (density === "compact") root.classList.add("density-compact");
|
||||
if (density === "spacious") root.classList.add("density-spacious");
|
||||
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
@@ -16,7 +29,7 @@ function AppLayout() {
|
||||
<Topbar />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 overflow-auto p-4 md:p-6"
|
||||
className="flex-1 overflow-auto p-[calc(1rem*var(--density-scale))] md:p-[calc(1.5rem*var(--density-scale))]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, EmptyState } from "@/components/state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -57,8 +58,20 @@ function AgentActivityPage() {
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "agent_activity" || data.type === "activity") {
|
||||
setLiveActivities((prev) => [data.payload, ...prev].slice(0, 5));
|
||||
// Realtime events are flat: { type: entityType, action, id, workspace_id }.
|
||||
// Match only agent events so unrelated task/habit/etc. activity doesn't leak in.
|
||||
if (data.type === "agent") {
|
||||
const entry: AgentActivity = {
|
||||
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
agentId: data.id,
|
||||
action: data.action,
|
||||
description: `Live update: ${data.action}`,
|
||||
entityType: "agent",
|
||||
entityId: data.id,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -117,9 +130,9 @@ function AgentActivityPage() {
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading activity...</div>
|
||||
<LoadingState label="Loading activity..." />
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No activity found</div>
|
||||
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{activities.map((a, idx) => (
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Download, Calendar, TrendingUp, BarChart3, PieChart, Activity, Grid3X3 } from "lucide-react";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { Download, Calendar } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, ErrorState } from "@/components/state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProductivityData, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
||||
import { format, subDays, parseISO, startOfMonth, eachDayOfInterval } from "date-fns";
|
||||
import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
||||
import { format, subDays } from "date-fns";
|
||||
|
||||
// Simple SVG-based charts (no recharts dependency needed for basic charts)
|
||||
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
||||
@@ -34,19 +34,27 @@ function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data
|
||||
);
|
||||
}
|
||||
|
||||
function BarChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
||||
function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) {
|
||||
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
||||
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
|
||||
const barWidth = Math.max(20, Math.min(40, (300 / data.length)));
|
||||
const width = Math.max(data.length * (barWidth + 4) + 40, 200);
|
||||
const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0);
|
||||
const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1);
|
||||
const series = yKey2 ? 2 : 1;
|
||||
const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series));
|
||||
const width = Math.max(data.length * (barWidth * series + 4) + 40, 200);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
|
||||
{data.map((d, i) => {
|
||||
const barH = (d[yKey] / maxVal) * (height - 30);
|
||||
const x = i * (barWidth + 4) + 20;
|
||||
const barH = (valOf(d, yKey) / maxVal) * (height - 30);
|
||||
const x = i * (barWidth * series + 4) + 20;
|
||||
const y = height - 20 - barH;
|
||||
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
|
||||
})}
|
||||
{yKey2 && data.map((d, i) => {
|
||||
const barH = (valOf(d, yKey2) / maxVal) * (height - 30);
|
||||
const x = i * (barWidth * series + 4) + 20 + barWidth;
|
||||
const y = height - 20 - barH;
|
||||
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color2} rx="2" />;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -129,23 +137,22 @@ function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [range, setRange] = useState("30");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
|
||||
const { data: prodData } = useApiQuery<ProductivityData>(["analytics-productivity", range], "/analytics/productivity?range=" + range);
|
||||
const { data: habitData } = useApiQuery<HabitAnalytics>(["analytics-habits", range], "/analytics/habits?range=" + range);
|
||||
const { data: projectData } = useApiQuery<ProjectAnalytics>(["analytics-projects", range], "/analytics/projects?range=" + range);
|
||||
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
|
||||
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
|
||||
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
|
||||
|
||||
// Generate mock daily data for charts (real API returns aggregated, we simulate daily breakdown)
|
||||
const dailyData = useMemo(() => {
|
||||
const days = parseInt(range);
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const d = subDays(new Date(), days - 1 - i);
|
||||
return {
|
||||
date: format(d, "yyyy-MM-dd"),
|
||||
completed: Math.floor(Math.random() * 5),
|
||||
created: Math.floor(Math.random() * 8) + 1,
|
||||
};
|
||||
});
|
||||
}, [range]);
|
||||
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
|
||||
const analyticsError = habitsError || projectsError || dailyError;
|
||||
const refetchAnalytics = () => {
|
||||
refetchHabits();
|
||||
refetchProjects();
|
||||
refetchDaily();
|
||||
};
|
||||
|
||||
const dailyItems = dailyData?.items || [];
|
||||
|
||||
const habitRateData = useMemo(() => {
|
||||
return [
|
||||
@@ -182,15 +189,20 @@ function AnalyticsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{analyticsLoading ? (
|
||||
<LoadingState label="Loading analytics..." />
|
||||
) : analyticsError ? (
|
||||
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Tasks completed per day */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<LineChart data={dailyData} xKey="date" yKey="completed" />
|
||||
<LineChart data={dailyItems} xKey="date" yKey="completed" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -198,10 +210,10 @@ function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyData.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<BarChart data={dailyData} xKey="date" yKey="created" color="#f97316" />
|
||||
<BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" />
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span>
|
||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
|
||||
@@ -224,34 +236,37 @@ function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Metric", "Value"], ["Rate", String(projectData?.taskCompletionRate || 0)], ["Total", String(projectData?.totalTasks || 0)], ["Completed", String(projectData?.completedTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Project", "Total Tasks", "Completed", "Progress"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks), String(p.completedTasks), String(Math.round(p.progress * 100)) + "%"])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold">{projectData?.taskCompletionRate || 0}%</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
||||
{projectData?.projects.length ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{projectData.totalProjects} project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
|
||||
<HorizontalBar
|
||||
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
|
||||
xKey="name"
|
||||
yKey="progress"
|
||||
height={Math.max(100, projectData.projects.length * 26)}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold">{projectData?.totalTasks || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Total</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted/50 rounded">
|
||||
<p className="text-lg font-bold text-green-500">{projectData?.completedTasks || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time spent per domain (pie) */}
|
||||
{/* Tasks by project (pie) */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Time per Domain</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("time-per-domain.csv", [["Domain", "Tasks"], ["Default", String(prodData?.totalTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<PieChartSimple data={[{ name: "Default", value: prodData?.totalTasks || 1 }]} labelKey="name" valueKey="value" />
|
||||
{projectData?.projects.length ? (
|
||||
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -259,13 +274,14 @@ function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CalendarHeatmap data={dailyData.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, EmptyState } from "@/components/state";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -14,9 +17,10 @@ import type { CalendarEvent } from "@/lib/types";
|
||||
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
|
||||
|
||||
// react-big-calendar
|
||||
import { Calendar, dateFnsLocalizer, Views, Navigate } from "react-big-calendar";
|
||||
// import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
||||
import { Calendar, dateFnsLocalizer, Navigate, type View, type stringOrDate } from "react-big-calendar";
|
||||
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
|
||||
|
||||
const localizer = dateFnsLocalizer({
|
||||
startOfWeek,
|
||||
@@ -25,7 +29,10 @@ const localizer = dateFnsLocalizer({
|
||||
locales: {},
|
||||
});
|
||||
|
||||
// const DragAndDropCalendar = withDragAndDrop(Calendar);
|
||||
// Mapped calendar event shape used by react-big-calendar (adds Date start/end accessors)
|
||||
type CalendarViewEvent = CalendarEvent & { start: Date; end: Date };
|
||||
|
||||
const DragAndDropCalendar = withDragAndDrop<CalendarViewEvent>(Calendar);
|
||||
|
||||
// Color palette: tasks=accent, events by domain
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
@@ -95,11 +102,21 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
||||
const [color, setColor] = useState(event?.color || "#3b82f6");
|
||||
|
||||
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
toast.success("Event created");
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to create event"),
|
||||
});
|
||||
|
||||
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
toast.success("Event updated");
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to update event"),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -147,7 +164,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
||||
function CalendarPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [date, setDate] = useState(new Date());
|
||||
const [view, setView] = useState<string>("month");
|
||||
const [view, setView] = useState<View>("month");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
||||
@@ -163,19 +180,27 @@ function CalendarPage() {
|
||||
}, []);
|
||||
|
||||
|
||||
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||
["calendar-events", date.toISOString()],
|
||||
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}`
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||
["calendar-events", activeDomainId, date.toISOString()],
|
||||
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
const events = eventsData?.items || [];
|
||||
|
||||
const deleteMutation = useApiMutation<any, string>("delete", "", {
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); },
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
setEventDetailOpen(false);
|
||||
toast.success("Event deleted");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to delete event"),
|
||||
});
|
||||
|
||||
// Map API events to react-big-calendar format
|
||||
const calendarEvents = useMemo(() => {
|
||||
const calendarEvents = useMemo<CalendarViewEvent[]>(() => {
|
||||
return events.map((evt) => ({
|
||||
...evt,
|
||||
start: parseISO(evt.startTime),
|
||||
@@ -193,10 +218,10 @@ function CalendarPage() {
|
||||
}, []);
|
||||
|
||||
const handleEventDrop = useCallback(
|
||||
({ event, start, end }: { event: any; start: Date; end: Date }) => {
|
||||
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
||||
api.patch(`/calendar/events/${event.id}`, {
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
||||
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
});
|
||||
@@ -205,10 +230,10 @@ function CalendarPage() {
|
||||
);
|
||||
|
||||
const handleEventResize = useCallback(
|
||||
({ event, start, end }: { event: any; start: Date; end: Date }) => {
|
||||
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
||||
api.patch(`/calendar/events/${event.id}`, {
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
||||
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
});
|
||||
@@ -221,7 +246,7 @@ function CalendarPage() {
|
||||
}, []);
|
||||
|
||||
const handleViewChange = useCallback((newView: string) => {
|
||||
setView(newView);
|
||||
setView(newView as View);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -245,38 +270,54 @@ function CalendarPage() {
|
||||
key={name}
|
||||
variant={view === name ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setView(name)}
|
||||
onClick={() => setView(name as View)}
|
||||
className="capitalize"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
|
||||
<Calendar
|
||||
key={view}
|
||||
localizer={localizer}
|
||||
events={calendarEvents}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
date={date}
|
||||
view={view}
|
||||
onNavigate={handleNavigate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onSelectSlot={handleSelectSlot}
|
||||
selectable
|
||||
popup
|
||||
showMultiDayTimes
|
||||
components={{
|
||||
event: EventComponent,
|
||||
toolbar: (props: any) => <CustomToolbar {...props} />,
|
||||
}}
|
||||
views={["month", "week", "work_week", "day", "agenda"]}
|
||||
step={30}
|
||||
timeslots={2}
|
||||
style={{ height: isMobile ? 400 : 600 }}
|
||||
{eventsLoading && events.length === 0 ? (
|
||||
<LoadingState label="Loading events..." />
|
||||
) : !eventsLoading && events.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No events yet"
|
||||
description="Create your first event to get started"
|
||||
action={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />New Event
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
|
||||
<DragAndDropCalendar
|
||||
key={view}
|
||||
localizer={localizer}
|
||||
events={calendarEvents}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
date={date}
|
||||
view={view}
|
||||
onNavigate={handleNavigate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onEventDrop={handleEventDrop}
|
||||
onEventResize={handleEventResize}
|
||||
selectable
|
||||
popup
|
||||
showMultiDayTimes
|
||||
components={{
|
||||
event: EventComponent,
|
||||
toolbar: (props: any) => <CustomToolbar {...props} />,
|
||||
}}
|
||||
views={["month", "week", "work_week", "day", "agenda"]}
|
||||
step={30}
|
||||
timeslots={2}
|
||||
style={{ height: isMobile ? 400 : 600 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event detail dialog */}
|
||||
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -186,13 +186,26 @@ function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
|
||||
|
||||
// ─── Canvas Editor ────────────────────────────────────────────────────────
|
||||
|
||||
function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
||||
type Block = { id: string; type: string; content: string };
|
||||
|
||||
export function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [blocks, setBlocks] = useState<Array<{ id: string; type: string; content: string }>>(
|
||||
const [blocks, setBlocks] = useState<Block[]>(
|
||||
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
|
||||
);
|
||||
const [title, setTitle] = useState(canvas.name);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const blockIdCounter = useRef(blocks.length + 1);
|
||||
const lastSavedTitle = useRef(canvas.name);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isDirty = useRef(false);
|
||||
const lastSeen = useRef<{ blocks: Block[]; title: string }>({ blocks, title });
|
||||
|
||||
// Keep latest values reachable from the debounced save without stale closures
|
||||
const blocksRef = useRef(blocks);
|
||||
blocksRef.current = blocks;
|
||||
const titleRef = useRef(title);
|
||||
titleRef.current = title;
|
||||
|
||||
// Listen for block type changes
|
||||
useEffect(() => {
|
||||
@@ -204,10 +217,84 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
||||
return () => window.removeEventListener("change-block-type", handler);
|
||||
}, []);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.patch("/canvas/" + canvas.id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
||||
});
|
||||
// Persist the full block list via the bulk endpoint, then adopt the
|
||||
// server-generated ids for newly created blocks (content stays local).
|
||||
const persist = useCallback(async () => {
|
||||
const currentBlocks = blocksRef.current;
|
||||
const currentTitle = titleRef.current;
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const payload = {
|
||||
cards: currentBlocks.map((b, i) => ({
|
||||
...(b.id.startsWith("new-") || b.id.startsWith("block-") ? {} : { id: b.id }),
|
||||
type: b.type,
|
||||
content: b.content,
|
||||
zIndex: i,
|
||||
})),
|
||||
};
|
||||
const result = await api.put<{ cards: { id: string }[] }>("/canvas/" + canvas.id + "/cards", payload);
|
||||
setBlocks((prev) => {
|
||||
if (result.cards.length !== prev.length) return prev;
|
||||
let changed = false;
|
||||
const next = prev.map((b, i) => {
|
||||
const newId = result.cards[i]?.id;
|
||||
if (newId && b.id !== newId) {
|
||||
changed = true;
|
||||
return { ...b, id: newId };
|
||||
}
|
||||
return b;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
if (currentTitle !== lastSavedTitle.current) {
|
||||
await api.patch("/canvas/" + canvas.id, { name: currentTitle });
|
||||
lastSavedTitle.current = currentTitle;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
isDirty.current = false;
|
||||
setSaveState("saved");
|
||||
} catch (error) {
|
||||
console.error("[canvas] save failed:", error);
|
||||
setSaveState("error");
|
||||
}
|
||||
}, [canvas.id, queryClient]);
|
||||
|
||||
// Debounced autosave: persist shortly after blocks/title stop changing
|
||||
useEffect(() => {
|
||||
if (lastSeen.current.blocks === blocks && lastSeen.current.title === title) {
|
||||
return;
|
||||
}
|
||||
lastSeen.current = { blocks, title };
|
||||
isDirty.current = true;
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
persist();
|
||||
}, 800);
|
||||
return () => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
};
|
||||
}, [blocks, title, persist]);
|
||||
|
||||
// Flush any unsaved edits when leaving the editor
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isDirty.current) {
|
||||
persist();
|
||||
}
|
||||
};
|
||||
}, [persist]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
persist();
|
||||
};
|
||||
|
||||
const handleBlockChange = (id: string, content: string) => {
|
||||
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
|
||||
@@ -240,10 +327,6 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
||||
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({ name: title });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -252,11 +335,15 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>Save</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{saveState === "saving" && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
{saveState === "saved" && <span className="text-xs text-muted-foreground">Saved</span>}
|
||||
{saveState === "error" && <span className="text-xs text-destructive">Save failed</span>}
|
||||
<Button size="sm" onClick={handleSave} disabled={saveState === "saving"}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
@@ -292,7 +379,7 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
||||
|
||||
function CanvasList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCanvas, setSelectedCanvas] = useState<Canvas | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
@@ -305,7 +392,7 @@ function CanvasList() {
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
setCreateOpen(false);
|
||||
setNewName("");
|
||||
setSelectedCanvas(canvas);
|
||||
navigate({ to: "/canvas/$id", params: { id: canvas.id } });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -314,20 +401,6 @@ function CanvasList() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
||||
});
|
||||
|
||||
const openCanvas = async (id: string) => {
|
||||
try {
|
||||
const detail = await api.get<Canvas>("/canvas/" + id);
|
||||
setSelectedCanvas(detail);
|
||||
} catch {
|
||||
const c = canvases.find((c) => c.id === id);
|
||||
if (c) setSelectedCanvas(c);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedCanvas) {
|
||||
return <CanvasEditor canvas={selectedCanvas} onBack={() => setSelectedCanvas(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -356,7 +429,7 @@ function CanvasList() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{canvases.map((c) => (
|
||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openCanvas(c.id)}>
|
||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../../_app";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowLeft, Layout, Layers } from "lucide-react";
|
||||
import type { Canvas } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { CanvasEditor } from "../canvas";
|
||||
|
||||
function CanvasDetail() {
|
||||
const { id } = useParams({ from: Route.id });
|
||||
@@ -17,50 +12,7 @@ function CanvasDetail() {
|
||||
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
|
||||
|
||||
const blockCount = canvas.cards?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<Button variant="ghost" onClick={() => navigate({ to: "/canvas" })} className="w-fit">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Canvas
|
||||
</Button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Layout className="h-6 w-6 text-muted-foreground" />
|
||||
<CardTitle className="text-2xl">{canvas.name}</CardTitle>
|
||||
<Badge variant="secondary">{canvas.mode}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{canvas.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||
<p className="text-sm whitespace-pre-wrap">{canvas.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<Layers className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
|
||||
<p className="text-2xl font-bold">{blockCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Blocks</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<Layout className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
|
||||
<p className="text-2xl font-bold capitalize">{canvas.mode}</p>
|
||||
<p className="text-xs text-muted-foreground">Mode</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Created: {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</p>
|
||||
<p>Updated: {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return <CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />;
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
|
||||
@@ -3,17 +3,18 @@ import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Smile, Zap } from "lucide-react";
|
||||
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyNote } from "@/lib/types";
|
||||
import { format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
||||
|
||||
// ─── Calendar Sidebar ────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,7 +28,12 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
||||
// Check which dates have notes
|
||||
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
|
||||
const notes = data?.items || [];
|
||||
const noteDates = new Set(notes.map((n) => format(parseISO(n.date), "yyyy-MM-dd")));
|
||||
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
|
||||
// Slicing off the time portion yields the calendar date the note belongs to
|
||||
// regardless of the browser's timezone. parseISO + format would re-render the
|
||||
// UTC instant in the local zone and shift the marker to the previous day for
|
||||
// users west of UTC.
|
||||
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0">
|
||||
@@ -88,8 +94,16 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
const [energy, setEnergy] = useState<number | null>(null);
|
||||
const [noteId, setNoteId] = useState<string | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [saveTimer, setSaveTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const noteIdRef = useRef<string | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevDateStrRef = useRef(dateStr);
|
||||
|
||||
// Mirror noteId into a ref so a pending autosave timer can always read the
|
||||
// latest id. Without this, a timer scheduled while no note existed yet would
|
||||
// fire with a stale null and double-create the note once createMutation
|
||||
// resolves (noteId is set asynchronously in onSuccess).
|
||||
noteIdRef.current = noteId;
|
||||
|
||||
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
|
||||
["daily-note", dateStr],
|
||||
@@ -97,6 +111,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Switching days must cancel any pending autosave so it can't fire against
|
||||
// the newly loaded note (or with the previous day's closure state). The
|
||||
// guard on prevDateStrRef keeps refetches of the same day from wiping a
|
||||
// debounce that is still in flight.
|
||||
if (prevDateStrRef.current !== dateStr) {
|
||||
prevDateStrRef.current = dateStr;
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
if (note) {
|
||||
setContent(note.content || "");
|
||||
setMood(note.mood);
|
||||
@@ -112,6 +137,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
}
|
||||
}, [note, isLoading, dateStr]);
|
||||
|
||||
// Clear any pending autosave when the editor unmounts so a stale timer can't
|
||||
// fire after navigation away from the page.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
|
||||
onSuccess: (saved) => {
|
||||
@@ -131,16 +167,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
});
|
||||
|
||||
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
const timer = setTimeout(() => {
|
||||
if (noteId) {
|
||||
updateMutation.mutate({ id: noteId, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
const id = noteIdRef.current;
|
||||
if (id) {
|
||||
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
||||
} else if (newContent.trim()) {
|
||||
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
|
||||
}
|
||||
}, 1500);
|
||||
setSaveTimer(timer);
|
||||
}, [noteId, dateStr, saveTimer]);
|
||||
}, [dateStr, updateMutation, createMutation]);
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setContent(value);
|
||||
@@ -151,6 +188,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
setMood(value);
|
||||
if (noteId) {
|
||||
updateMutation.mutate({ id: noteId, data: { mood: value } });
|
||||
} else if (isNew && !createMutation.isPending) {
|
||||
// No note exists for this day yet — create it so the mood is recorded
|
||||
// even before any content is typed.
|
||||
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,18 +199,68 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
setEnergy(value);
|
||||
if (noteId) {
|
||||
updateMutation.mutate({ id: noteId, data: { energy: value } });
|
||||
} else if (isNew && !createMutation.isPending) {
|
||||
// No note exists for this day yet — create it so the energy is recorded
|
||||
// even before any content is typed.
|
||||
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
try {
|
||||
await api.delete("/daily-notes/" + id);
|
||||
} catch (error) {
|
||||
// The API responds 204 No Content, which has no JSON body, so api.delete
|
||||
// (which resolves res.json()) rejects with a SyntaxError on the empty
|
||||
// body even though the server-side delete succeeded. Re-throw anything
|
||||
// else (real HTTP/network failures).
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setContent("");
|
||||
setMood(null);
|
||||
setEnergy(null);
|
||||
setNoteId(null);
|
||||
setIsNew(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
||||
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (noteId) deleteMutation.mutate(noteId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{noteId && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
<Save className="h-3 w-3 mr-1" />Saved
|
||||
</Badge>
|
||||
<>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
<Save className="h-3 w-3 mr-1" />Saved
|
||||
</Badge>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Daily Note</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete this daily note? This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,10 +269,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
||||
<div className="flex gap-1">
|
||||
<div role="radiogroup" aria-label="Mood" className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
role="radio"
|
||||
aria-checked={mood === v}
|
||||
aria-label={`Mood ${v}`}
|
||||
onClick={() => handleMoodChange(v)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||
@@ -195,10 +289,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
||||
<div className="flex gap-1">
|
||||
<div role="radiogroup" aria-label="Energy" className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
role="radio"
|
||||
aria-checked={energy === v}
|
||||
aria-label={`Energy ${v}`}
|
||||
onClick={() => handleEnergyChange(v)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useActiveDomainId } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, EmptyState } from "@/components/state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -30,12 +32,26 @@ const ENTITY_COLORS: Record<string, string> = {
|
||||
domain: "#6366f1",
|
||||
};
|
||||
|
||||
// Graph node types that have a detail page. section/tag/domain nodes appear in
|
||||
// the graph but have no detail route, so they are intentionally absent.
|
||||
const NODE_TYPE_ROUTES: Record<string, string> = {
|
||||
task: "/tasks/$id",
|
||||
habit: "/habits/$id",
|
||||
project: "/projects/$id",
|
||||
note: "/notes/$id",
|
||||
};
|
||||
|
||||
const MAX_NODES = 500;
|
||||
|
||||
function GraphPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<any>(undefined);
|
||||
// Simulated node positions, keyed by node id. react-force-graph assigns x/y to
|
||||
// the node objects it renders during the simulation; the raw API nodes
|
||||
// (displayNodes) never gain coordinates, so fly-to must look here instead.
|
||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
@@ -70,9 +86,16 @@ function GraphPage() {
|
||||
["domains"],
|
||||
"/domains"
|
||||
);
|
||||
const activeDomainId = domainsData?.items?.[0]?.id || "";
|
||||
// Use the active domain from the store, falling back to the first domain
|
||||
// while unset. Validate against the fetched list so a persisted id that no
|
||||
// longer exists doesn't produce a query for a deleted domain.
|
||||
const storedDomainId = useActiveDomainId();
|
||||
const activeDomainId =
|
||||
(storedDomainId && domainsData?.items?.some((d) => d.id === storedDomainId) ? storedDomainId : null) ||
|
||||
domainsData?.items?.[0]?.id ||
|
||||
"";
|
||||
|
||||
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||
const { data: nodesData, isLoading: nodesLoading } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||
["graph", "nodes", activeDomainId],
|
||||
"/graph/nodes?domain=" + activeDomainId,
|
||||
{ enabled: !!activeDomainId }
|
||||
@@ -154,14 +177,34 @@ function GraphPage() {
|
||||
};
|
||||
|
||||
// Search: fly to node
|
||||
const trackNodePosition = useCallback((node: any) => {
|
||||
if (node && typeof node.x === "number" && typeof node.y === "number") {
|
||||
positionsRef.current.set(String(node.id), { x: node.x, y: node.y });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Snapshot every simulated node's position once the force simulation settles.
|
||||
const snapshotPositions = useCallback(() => {
|
||||
const nodes = graphRef.current?.graphData()?.nodes;
|
||||
if (!nodes) return;
|
||||
for (const node of nodes) {
|
||||
trackNodePosition(node);
|
||||
}
|
||||
}, [trackNodePosition]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!search.trim() || !graphRef.current) return;
|
||||
const found = displayNodes.find(
|
||||
(n) => n.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
if (found) {
|
||||
graphRef.current.centerAt(found.x, found.y, 1000);
|
||||
if (!found) return;
|
||||
const pos = positionsRef.current.get(found.id);
|
||||
if (pos) {
|
||||
graphRef.current.centerAt(pos.x, pos.y, 1000);
|
||||
graphRef.current.zoom(3, 1000);
|
||||
} else {
|
||||
// No coordinates yet (e.g. simulation still warming up) — fit the view instead.
|
||||
graphRef.current.zoomToFit(1000, 50);
|
||||
}
|
||||
}, [search, displayNodes]);
|
||||
|
||||
@@ -175,6 +218,15 @@ function GraphPage() {
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
// Close the detail panel and navigate to the node's detail page when one
|
||||
// exists (task/habit/project/note). No-ops for types without a detail route.
|
||||
const handleOpenEntity = useCallback((node: GraphNode) => {
|
||||
const to = NODE_TYPE_ROUTES[node.type];
|
||||
if (!to) return;
|
||||
setDetailOpen(false);
|
||||
navigate({ to, params: { id: node.id } });
|
||||
}, [navigate]);
|
||||
|
||||
// Node hover → highlight
|
||||
const handleNodeHover = useCallback((node: any | null) => {
|
||||
setHoveredNode(node as GraphNode | null);
|
||||
@@ -273,7 +325,7 @@ function GraphPage() {
|
||||
{/* Graph canvas area */}
|
||||
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -281,7 +333,7 @@ function GraphPage() {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-8 w-64 bg-background/90 backdrop-blur"
|
||||
className="pl-8 w-40 sm:w-64 bg-background/90 backdrop-blur"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
||||
@@ -312,24 +364,35 @@ function GraphPage() {
|
||||
)}
|
||||
|
||||
{/* Force graph */}
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
linkCanvasObject={linkCanvasObject}
|
||||
linkDirectionalArrowLength={0}
|
||||
linkDirectionalArrowRelPos={0.5}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={handleNodeHover}
|
||||
nodeRelSize={6}
|
||||
d3AlphaDecay={0.02}
|
||||
d3VelocityDecay={0.3}
|
||||
cooldownTicks={100}
|
||||
warmupTicks={40}
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
{activeDomainId && nodesLoading ? (
|
||||
<LoadingState label="Loading graph..." />
|
||||
) : displayNodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No graph data yet"
|
||||
description="Create tasks, habits, or projects to see them connected here"
|
||||
/>
|
||||
) : (
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
linkCanvasObject={linkCanvasObject}
|
||||
linkDirectionalArrowLength={0}
|
||||
linkDirectionalArrowRelPos={0.5}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeDrag={trackNodePosition}
|
||||
onEngineStop={snapshotPositions}
|
||||
nodeRelSize={6}
|
||||
d3AlphaDecay={0.02}
|
||||
d3VelocityDecay={0.3}
|
||||
cooldownTicks={100}
|
||||
warmupTicks={40}
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
@@ -391,6 +454,19 @@ function GraphPage() {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
|
||||
<Separator />
|
||||
{NODE_TYPE_ROUTES[selectedNode.type] ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleOpenEntity(selectedNode)}
|
||||
>
|
||||
Open {selectedNode.type.charAt(0).toUpperCase() + selectedNode.type.slice(1)}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No detail page for {selectedNode.type}
|
||||
</p>
|
||||
)}
|
||||
<Separator />
|
||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
||||
<div className="space-y-1">
|
||||
{displayEdges
|
||||
@@ -401,7 +477,22 @@ function GraphPage() {
|
||||
return connected ? (
|
||||
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
|
||||
<span className="truncate flex-1">{connected.label}</span>
|
||||
{NODE_TYPE_ROUTES[connected.type] ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenEntity(connected)}
|
||||
className="truncate flex-1 text-left hover:underline"
|
||||
>
|
||||
{connected.label}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate flex-1">{connected.label}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
No detail page for {connected.type}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Flame, Trash2, Check, Calendar, TrendingUp } from "lucide-react";
|
||||
import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -15,6 +16,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
||||
@@ -58,7 +60,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="freq">Frequency</Label>
|
||||
<Select value={frequency} onValueChange={setFrequency}>
|
||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
|
||||
<SelectTrigger id="freq"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
@@ -69,7 +71,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="diff">Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={setDifficulty}>
|
||||
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as "easy" | "medium" | "hard")}>
|
||||
<SelectTrigger id="diff"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
@@ -113,6 +115,7 @@ function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; d
|
||||
}
|
||||
|
||||
function HabitsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
|
||||
@@ -121,9 +124,11 @@ function HabitsPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: habitsData, isLoading } = useApiQuery<PaginatedResponse<Habit>>(
|
||||
["habits"],
|
||||
"/habits?limit=200"
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
|
||||
["habits", activeDomainId],
|
||||
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
const habits = habitsData?.items || [];
|
||||
@@ -138,7 +143,11 @@ function HabitsPage() {
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
|
||||
});
|
||||
|
||||
const openHabitDetail = async (habit: Habit) => {
|
||||
const openHabitDetail = (habit: Habit) => {
|
||||
navigate({ to: "/habits/$id", params: { id: habit.id } });
|
||||
};
|
||||
|
||||
const openHabitPanel = async (habit: Habit) => {
|
||||
try {
|
||||
const detail = await api.get<Habit>("/habits/" + habit.id);
|
||||
setSelectedHabit(detail);
|
||||
@@ -164,9 +173,11 @@ function HabitsPage() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading habits...</div>
|
||||
<LoadingState label="Loading habits..." />
|
||||
) : isError ? (
|
||||
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
|
||||
) : habits.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No habits yet. Create your first one!</div>
|
||||
<EmptyState icon={Flame} title="No habits yet" description="Create your first one!" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{habits.map((habit) => (
|
||||
@@ -197,6 +208,15 @@ function HabitsPage() {
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />Complete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => { e.stopPropagation(); openHabitPanel(habit); }}
|
||||
aria-label={"Edit " + habit.name}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowLeft, Flame, Calendar } from "lucide-react";
|
||||
import { TagManager } from "@/components/entities/tag-manager";
|
||||
import { ArrowLeft, Flame, Calendar, Clock } from "lucide-react";
|
||||
import type { Habit, HabitCompletion } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
@@ -33,6 +34,10 @@ function HabitDetail() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</div>
|
||||
{habit.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||
@@ -55,6 +60,8 @@ function HabitDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
|
||||
{completions.length === 0 ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -30,7 +31,7 @@ const WIDGET_TYPES = [
|
||||
] as const;
|
||||
|
||||
function TasksDueWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=dueDate");
|
||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date");
|
||||
const tasks = data?.items || [];
|
||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
||||
@@ -74,7 +75,12 @@ function HabitsTodayWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-today"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["habits-today"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["streaks"] });
|
||||
toast.success("Habit completed");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to complete habit"),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -197,11 +203,21 @@ function QuickCaptureWidget() {
|
||||
const [type, setType] = useState<"task" | "note">("task");
|
||||
const createTask = useMutation({
|
||||
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); setText(""); },
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
|
||||
setText("");
|
||||
toast.success("Task added");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to create task"),
|
||||
});
|
||||
const createNote = useMutation({
|
||||
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); setText(""); },
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
|
||||
setText("");
|
||||
toast.success("Note added");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to create note"),
|
||||
});
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -293,7 +309,10 @@ function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget
|
||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
|
||||
const Icon = typeInfo?.icon || Target;
|
||||
return (
|
||||
<Card className="h-full flex flex-col group" style={{ gridColumn: "span " + (widget.layout.w || 2), gridRow: "span " + (widget.layout.h || 2) }}>
|
||||
<Card
|
||||
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
|
||||
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
|
||||
>
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -397,15 +416,27 @@ function DashboardPage() {
|
||||
const widgets = widgetsData?.items || [];
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
|
||||
toast.success("Widget added");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to add widget"),
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
|
||||
toast.success("Widget updated");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to update widget"),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
|
||||
toast.success("Widget removed");
|
||||
},
|
||||
onError: (err) => toast.error(err.message || "Failed to remove widget"),
|
||||
});
|
||||
const handleAddWidget = (type: string) => {
|
||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
||||
@@ -431,7 +462,7 @@ function DashboardPage() {
|
||||
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(12, 1fr)", gridAutoRows: "minmax(120px, auto)" }}>
|
||||
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{widgets.map((w) => (
|
||||
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,38 +12,92 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Note, PaginatedResponse } from "@/lib/types";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
|
||||
// Simple TipTap-like editor using contentEditable - saves on blur only
|
||||
const AUTOSAVE_DEBOUNCE_MS = 800;
|
||||
|
||||
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
|
||||
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
|
||||
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
|
||||
const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaceholder, setIsPlaceholder] = useState(!initialContent);
|
||||
const latestHtmlRef = useRef(initialContent || "");
|
||||
const dirtyRef = useRef(false);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: initialContent || "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "focus:outline-none min-h-[300px] p-3",
|
||||
},
|
||||
},
|
||||
},
|
||||
[placeholder, initialContent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !editorRef.current.innerHTML) {
|
||||
editorRef.current.innerHTML = initialContent || "";
|
||||
}
|
||||
setIsPlaceholder(!initialContent);
|
||||
}, []);
|
||||
if (!editor) return;
|
||||
|
||||
const handleBlur = () => {
|
||||
const html = editorRef.current?.innerHTML || "";
|
||||
onSave(html);
|
||||
};
|
||||
const flushSave = () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
if (!dirtyRef.current) return;
|
||||
dirtyRef.current = false;
|
||||
onSave(latestHtmlRef.current);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
latestHtmlRef.current = editor.getHTML();
|
||||
dirtyRef.current = true;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(flushSave, AUTOSAVE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
editor.on("update", handleUpdate);
|
||||
editor.on("blur", flushSave);
|
||||
|
||||
return () => {
|
||||
editor.off("update", handleUpdate);
|
||||
editor.off("blur", flushSave);
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
// Flush any unsaved edits on unmount so switching notes doesn't drop typing.
|
||||
if (dirtyRef.current) {
|
||||
dirtyRef.current = false;
|
||||
onSave(latestHtmlRef.current);
|
||||
}
|
||||
};
|
||||
}, [editor, onSave]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[300px]">
|
||||
{isPlaceholder && (
|
||||
<div className="absolute top-0 left-0 text-muted-foreground pointer-events-none p-3 text-sm">{placeholder}</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="prose prose-sm dark:prose-invert max-w-none p-3 focus:outline-none min-h-[300px]"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<div className="note-editor relative min-h-[300px]">
|
||||
{/* Placeholder needs its ::before styling; the @tailwindcss/typography plugin
|
||||
is not installed, so this is scoped CSS for the empty-editor state. */}
|
||||
<style>{`
|
||||
.note-editor p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: hsl(var(--muted-foreground));
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
`}</style>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -159,9 +214,11 @@ function NotesPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
|
||||
["notes", search],
|
||||
"/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "")
|
||||
["notes", activeDomainId, search],
|
||||
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "")
|
||||
);
|
||||
|
||||
const notes = notesData?.items || [];
|
||||
@@ -201,9 +258,9 @@ function NotesPage() {
|
||||
}, [deleteMutation]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
<div className="flex flex-col md:flex-row h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
{/* Left pane - note list */}
|
||||
<div className="w-72 border-r flex flex-col shrink-0">
|
||||
<div className="w-full md:w-72 h-64 md:h-auto border-b md:border-b-0 md:border-r flex flex-col shrink-0">
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -246,7 +303,7 @@ function NotesPage() {
|
||||
</div>
|
||||
|
||||
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-h-64 md:min-h-0">
|
||||
{selectedNote ? (
|
||||
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
|
||||
) : (
|
||||
|
||||
@@ -5,10 +5,21 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TagManager } from "@/components/entities/tag-manager";
|
||||
import { ArrowLeft, FileText, Clock } from "lucide-react";
|
||||
import type { Note } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
// Note content is Tiptap-generated HTML stored by the API. Lightweight sanitizer
|
||||
// applied before rendering via dangerouslySetInnerHTML: drops script/style
|
||||
// blocks, inline event handlers, and javascript: URLs.
|
||||
const sanitizeNoteHtml = (html: string): string =>
|
||||
html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "")
|
||||
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||
.replace(/(\shref|\ssrc)\s*=\s*(?:"|')\s*javascript:[^"']*(?:"|')/gi, ' $1=""');
|
||||
|
||||
function NoteDetail() {
|
||||
const { id } = useParams({ from: Route.id });
|
||||
const navigate = useNavigate();
|
||||
@@ -36,19 +47,38 @@ function NoteDetail() {
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="text-sm whitespace-pre-wrap">{note.content || "No content"}</p>
|
||||
{/* The @tailwindcss/typography plugin isn't installed, so style the
|
||||
Tiptap output with scoped CSS instead of `prose` classes. */}
|
||||
<div className="note-detail-content max-w-none">
|
||||
<style>{`
|
||||
.note-detail-content { line-height: 1.75; }
|
||||
.note-detail-content h1 { font-size: 1.75rem; font-weight: 700; line-height: 1.25; margin: 1.5rem 0 0.75rem; }
|
||||
.note-detail-content h2 { font-size: 1.5rem; font-weight: 700; line-height: 1.3; margin: 1.5rem 0 0.75rem; }
|
||||
.note-detail-content h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.4; margin: 1.25rem 0 0.5rem; }
|
||||
.note-detail-content h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; margin: 1rem 0 0.5rem; }
|
||||
.note-detail-content h5, .note-detail-content h6 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
|
||||
.note-detail-content p { margin: 0.75rem 0; }
|
||||
.note-detail-content a { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
.note-detail-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||
.note-detail-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||
.note-detail-content li { margin: 0.25rem 0; }
|
||||
.note-detail-content li p { margin: 0; }
|
||||
.note-detail-content blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1rem; margin: 1rem 0; color: hsl(var(--muted-foreground)); }
|
||||
.note-detail-content hr { border: 0; border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
|
||||
.note-detail-content code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.875em; background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
|
||||
.note-detail-content pre { background: hsl(var(--muted)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
|
||||
.note-detail-content pre code { background: transparent; padding: 0; font-size: 0.875rem; }
|
||||
.note-detail-content ul[data-type="taskList"] { list-style: none; padding-left: 0.25rem; }
|
||||
.note-detail-content ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.note-detail-content ul[data-type="taskList"] li p { flex: 1; }
|
||||
`}</style>
|
||||
{note.content ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeNoteHtml(note.content) }} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No content</p>
|
||||
)}
|
||||
</div>
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{note.tags.map((t: any) => (
|
||||
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -18,6 +19,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
@@ -61,7 +64,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as "active" | "paused" | "completed" | "archived")}>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
@@ -100,9 +103,11 @@ function ProjectsPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: projectsData, isLoading } = useApiQuery<PaginatedResponse<Project>>(
|
||||
["projects"],
|
||||
"/projects?limit=200"
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
|
||||
["projects", activeDomainId],
|
||||
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
const projects = projectsData?.items || [];
|
||||
@@ -112,7 +117,11 @@ function ProjectsPage() {
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
|
||||
});
|
||||
|
||||
const openProjectDetail = async (project: Project) => {
|
||||
const openProjectDetail = (project: Project) => {
|
||||
navigate({ to: "/projects/$id", params: { id: project.id } });
|
||||
};
|
||||
|
||||
const openProjectPanel = async (project: Project) => {
|
||||
try {
|
||||
const detail = await api.get<Project>("/projects/" + project.id);
|
||||
setSelectedProject(detail);
|
||||
@@ -138,9 +147,11 @@ function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading projects...</div>
|
||||
<LoadingState label="Loading projects..." />
|
||||
) : isError ? (
|
||||
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No projects yet.</div>
|
||||
<EmptyState title="No projects yet" description="Create your first project to get started." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
@@ -149,7 +160,18 @@ function ProjectsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px]">{project.status}</Badge>
|
||||
<Badge className={cn("ml-auto text-[10px]", PROJECT_STATUS[project.status]?.badge)}>
|
||||
{PROJECT_STATUS[project.status]?.label ?? project.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
|
||||
aria-label={"Edit " + project.name}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -208,7 +230,7 @@ function ProjectsPage() {
|
||||
{selectedProject.tasks.map((task: any) => (
|
||||
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||
<span className="truncate">{task.title}</span>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">{task.status}</Badge>
|
||||
<Badge className={cn("text-[10px] shrink-0", TASK_STATUS[task.status]?.badge)}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,18 +5,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ArrowLeft, Calendar, ListTodo, Activity } from "lucide-react";
|
||||
import { ArrowLeft, Calendar, Clock, ListTodo, Activity } from "lucide-react";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: "bg-green-500",
|
||||
paused: "bg-amber-500",
|
||||
completed: "bg-blue-500",
|
||||
archived: "bg-slate-500",
|
||||
};
|
||||
import { PROJECT_STATUS } from "@/lib/status-colors";
|
||||
|
||||
function ProjectDetail() {
|
||||
const { id } = useParams({ from: Route.id });
|
||||
@@ -27,7 +20,7 @@ function ProjectDetail() {
|
||||
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
|
||||
</Button>
|
||||
@@ -36,10 +29,14 @@ function ProjectDetail() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<CardTitle className="text-2xl">{project.name}</CardTitle>
|
||||
<Badge className={STATUS_COLORS[project.status] || "bg-slate-500"}>{project.status}</Badge>
|
||||
<Badge className={PROJECT_STATUS[project.status]?.badge}>{PROJECT_STATUS[project.status]?.label ?? project.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||
|
||||
@@ -20,6 +20,18 @@ const SEARCH_TYPES = [
|
||||
{ id: "domain", label: "Domains", color: "bg-indigo-500" },
|
||||
];
|
||||
|
||||
// Sanitize snippet HTML before it hits dangerouslySetInnerHTML. The API's
|
||||
// ts_headline output is safe text with matches wrapped in <mark>...</mark>.
|
||||
// Allow ONLY <mark> open/close tags (and only without event handler / href /
|
||||
// src attributes) so no other element, script, or attribute can be injected.
|
||||
const sanitizeSnippet = (html: string) =>
|
||||
html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
|
||||
.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?>/g, (full, tag) => {
|
||||
if (tag.toLowerCase() === "mark" && !/<[^>]*(?:on\w+=|href=|src=)/i.test(full)) return full;
|
||||
return "";
|
||||
});
|
||||
|
||||
function SearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -161,7 +173,7 @@ function SearchPage() {
|
||||
{result.snippet && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -13,7 +14,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
@@ -62,8 +62,21 @@ function AppearanceTab() {
|
||||
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
|
||||
|
||||
useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
|
||||
useEffect(() => { localStorage.setItem("density", density); }, [density]);
|
||||
useEffect(() => { localStorage.setItem("sidebar-position", sidebarPos); }, [sidebarPos]);
|
||||
// Density actually changes spacing now: toggle the density-* classes on <html>
|
||||
// (see the density utilities + --density-scale in index.css) and persist.
|
||||
useEffect(() => {
|
||||
localStorage.setItem("density", density);
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("density-compact", "density-spacious");
|
||||
if (density === "compact") root.classList.add("density-compact");
|
||||
if (density === "spacious") root.classList.add("density-spacious");
|
||||
}, [density]);
|
||||
// sidebarPos actually repositions the sidebar now: persist it and tell the
|
||||
// app shell (sidebar.tsx) to re-read it without a reload.
|
||||
useEffect(() => {
|
||||
localStorage.setItem("sidebar-position", sidebarPos);
|
||||
window.dispatchEvent(new CustomEvent("sidebar-position-change", { detail: sidebarPos }));
|
||||
}, [sidebarPos]);
|
||||
useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]);
|
||||
|
||||
return (
|
||||
@@ -420,16 +433,63 @@ function AgentsTab() {
|
||||
const agents = data?.items || [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
|
||||
const [editAgent, setEditAgent] = useState<Agent | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
status: "active",
|
||||
permissionTier: "read_only",
|
||||
customPermissions: "",
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: any) => api.post<Agent>("/agents", d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); },
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data: d }: { id: string; data: any }) => api.patch<Agent>("/agents/" + id, d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setEditAgent(null); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/agents/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }),
|
||||
});
|
||||
|
||||
const openEdit = (a: Agent) => {
|
||||
setEditAgent(a);
|
||||
setEditForm({
|
||||
name: a.name,
|
||||
description: a.description || "",
|
||||
status: a.status,
|
||||
permissionTier: a.permissionTier,
|
||||
customPermissions: (a.customPermissions || []).join(", "),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editAgent) return;
|
||||
const data: any = {
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description || null,
|
||||
status: editForm.status,
|
||||
permissionTier: editForm.permissionTier,
|
||||
};
|
||||
// customPermissions only apply to the "custom" tier; clear them otherwise so
|
||||
// a downgrade doesn't leave stale permissions in the database.
|
||||
data.customPermissions = editForm.permissionTier === "custom"
|
||||
? editForm.customPermissions.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
updateMutation.mutate({ id: editAgent.id, data });
|
||||
};
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
full_access: "Full Access",
|
||||
read_only: "Read Only",
|
||||
content_creator: "Content Creator",
|
||||
task_manager: "Task Manager",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -444,11 +504,9 @@ function AgentsTab() {
|
||||
<div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full_access">Full Access</SelectItem>
|
||||
<SelectItem value="read_only">Read Only</SelectItem>
|
||||
<SelectItem value="content_creator">Content Creator</SelectItem>
|
||||
<SelectItem value="task_manager">Task Manager</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
{Object.entries(permissionLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
|
||||
@@ -459,20 +517,73 @@ function AgentsTab() {
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.permissionTier.replace(/_/g, " ")} · {a.status}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{a.name}</p>
|
||||
{a.description && <p className="text-xs text-muted-foreground truncate">{a.description}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{permissionLabels[a.permissionTier] || a.permissionTier} · {a.status}
|
||||
</p>
|
||||
{a.permissionTier === "custom" && a.customPermissions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{a.customPermissions.map((p) => <Badge key={p} variant="secondary" className="text-[10px]">{p}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(a)} aria-label={"Edit " + a.name}><Pencil className="h-4 w-4" /></Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{editAgent && (
|
||||
<Dialog open={!!editAgent} onOpenChange={(o) => { if (!o) setEditAgent(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Agent</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Name</Label><Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
|
||||
<div><Label>Description</Label><Textarea value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} /></div>
|
||||
<div><Label>Status</Label><Select value={editForm.status} onValueChange={(v) => setEditForm({ ...editForm, status: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<div><Label>Permission Tier</Label><Select value={editForm.permissionTier} onValueChange={(v) => setEditForm({ ...editForm, permissionTier: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(permissionLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
{editForm.permissionTier === "custom" && (
|
||||
<div><Label>Custom Permissions (comma-separated)</Label><Input value={editForm.customPermissions} onChange={(e) => setEditForm({ ...editForm, customPermissions: e.target.value })} placeholder="tasks.write, notes.read, ..." /></div>
|
||||
)}
|
||||
{editAgent.apiKey && (
|
||||
<div>
|
||||
<Label>API Key</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={editAgent.apiKey} readOnly className="font-mono text-xs" aria-label="Agent API key" />
|
||||
<Button variant="outline" size="sm" className="h-9 shrink-0" onClick={() => { navigator.clipboard.writeText(editAgent.apiKey as string); toast.success("API key copied"); }}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Shown only here — store it securely before rotating.</p>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleSaveEdit} disabled={!editForm.name.trim() || updateMutation.isPending}>Save</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -487,7 +598,7 @@ function WebhooksTab() {
|
||||
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: any) => api.post<Webhook>("/webhooks", d),
|
||||
mutationFn: (d: any) => api.post<WebhookType>("/webhooks", d),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
@@ -546,6 +657,46 @@ function WebhooksTab() {
|
||||
|
||||
// ─── Import & Export Tab ─────────────────────────────────────────────────
|
||||
|
||||
// CSV export helpers. The export API always returns JSON; when the user picks
|
||||
// CSV we convert each collection client-side. Nested values (tags arrays,
|
||||
// customFields objects, etc.) are JSON-stringified into a single cell.
|
||||
|
||||
/** Escape a single CSV field per RFC 4180: wrap in quotes when needed, double inner quotes. */
|
||||
function csvEscape(value: string): string {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Render one cell: null/undefined → empty, scalars pass through, objects/arrays get JSON-stringified. */
|
||||
function csvCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return csvEscape(value);
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return csvEscape(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** Build a CSV document (header + one row per object) from an array of flat rows. */
|
||||
function rowsToCSV(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) return "";
|
||||
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
||||
const header = columns.map(csvEscape).join(",");
|
||||
const body = rows.map((r) => columns.map((c) => csvCell(r[c])).join(","));
|
||||
return [header, ...body].join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function ImportExportTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [importData, setImportData] = useState("");
|
||||
@@ -554,22 +705,76 @@ function ImportExportTab() {
|
||||
const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post("/import", data),
|
||||
onSuccess: (res) => { setImportResult(res); queryClient.invalidateQueries(); },
|
||||
mutationFn: (data: any) => api.post<any>("/import", data),
|
||||
onSuccess: (res) => {
|
||||
setImportResult(res);
|
||||
queryClient.invalidateQueries();
|
||||
if (res.success) {
|
||||
toast.success("Imported " + res.imported + " items");
|
||||
} else {
|
||||
toast.error("Imported " + (res.imported ?? 0) + " items, " + (res.failed ?? 0) + " failed");
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err.message || "Import failed";
|
||||
setImportResult({ success: false, error: message });
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleImport = () => {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(importData);
|
||||
} catch {
|
||||
const message = "Invalid JSON in import data";
|
||||
setImportResult({ success: false, error: message });
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const message = "Invalid format: expected an object with a version field";
|
||||
setImportResult({ success: false, error: message });
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!parsed.version) {
|
||||
const message = "Invalid format: missing version";
|
||||
setImportResult({ success: false, error: message });
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
importMutation.mutate(parsed);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api.post<any>("/export", { collections: exportCollections });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "project-e-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (exportFormat === "csv") {
|
||||
// One CSV file per selected collection; empty collections are skipped.
|
||||
let exported = 0;
|
||||
for (const collection of exportCollections) {
|
||||
const rows = Array.isArray(data[collection]) ? data[collection] : [];
|
||||
if (rows.length === 0) continue;
|
||||
const csv = rowsToCSV(rows);
|
||||
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8" }), "project-e-export-" + collection + ".csv");
|
||||
exported++;
|
||||
}
|
||||
if (exported === 0) {
|
||||
toast.error("No data to export for the selected collections");
|
||||
} else {
|
||||
toast.success("Exported " + exported + " CSV file" + (exported === 1 ? "" : "s"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON export — unchanged.
|
||||
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }), "project-e-export.json");
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Export failed";
|
||||
console.error("Export failed", e);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -583,12 +788,16 @@ function ImportExportTab() {
|
||||
<h3 className="text-lg font-semibold mb-3">Import</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p>
|
||||
<Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" />
|
||||
<Button className="mt-2" onClick={() => { try { importMutation.mutate(JSON.parse(importData)); } catch { setImportResult({ success: false, error: "Invalid JSON" }); } }} disabled={!importData.trim() || importMutation.isPending}>
|
||||
<Button className="mt-2" onClick={handleImport} disabled={!importData.trim() || importMutation.isPending}>
|
||||
<Upload className="h-4 w-4 mr-2" />Import
|
||||
</Button>
|
||||
{importResult && (
|
||||
<div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}>
|
||||
{importResult.success ? "Imported " + importResult.imported + " items" : "Import failed: " + (importResult.error || "Unknown error")}
|
||||
{importResult.success
|
||||
? "Imported " + importResult.imported + " items"
|
||||
: importResult.error
|
||||
? "Import failed: " + importResult.error
|
||||
: "Imported " + (importResult.imported ?? 0) + " items, " + (importResult.failed ?? 0) + " failed"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -691,9 +900,9 @@ function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("appearance");
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
||||
{/* Sidebar tabs */}
|
||||
<div className="w-56 shrink-0 space-y-1">
|
||||
<div className="flex flex-col md:flex-row gap-2 md:gap-6 h-auto md:h-[calc(100vh-5rem)]">
|
||||
{/* Tab bar - horizontal scrollable on mobile, vertical sidebar on md+ */}
|
||||
<div className="flex md:flex-col gap-1 md:gap-0 overflow-x-auto md:overflow-visible pb-1 md:pb-0 md:w-56 md:shrink-0 md:space-y-1 shrink-0">
|
||||
{SETTINGS_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
@@ -701,7 +910,8 @@ function SettingsPage() {
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left",
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left whitespace-nowrap shrink-0",
|
||||
"md:w-full",
|
||||
activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
@@ -711,7 +921,8 @@ function SettingsPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<Separator className="md:hidden" />
|
||||
<Separator orientation="vertical" className="hidden md:block" />
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
|
||||
@@ -3,11 +3,12 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
|
||||
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Plus, GripVertical, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
|
||||
import { Plus, GripVertical, Pencil, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -23,24 +24,20 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
||||
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||
import type { Task, PaginatedResponse } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_COLUMNS = [
|
||||
{ id: "todo", label: "Todo", color: "bg-slate-500" },
|
||||
{ id: "in_progress", label: "In Progress", color: "bg-blue-500" },
|
||||
{ id: "done", label: "Done", color: "bg-green-500" },
|
||||
{ id: "cancelled", label: "Cancelled", color: "bg-red-500" },
|
||||
{ id: "todo", label: "Todo" },
|
||||
{ id: "in_progress", label: "In Progress" },
|
||||
{ id: "done", label: "Done" },
|
||||
{ id: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: "text-red-500 bg-red-500/10",
|
||||
high: "text-orange-500 bg-orange-500/10",
|
||||
medium: "text-blue-500 bg-blue-500/10",
|
||||
low: "text-slate-500 bg-slate-500/10",
|
||||
};
|
||||
|
||||
function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () => void; onEdit?: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||
|
||||
const style = {
|
||||
@@ -64,8 +61,8 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>
|
||||
{task.priority}
|
||||
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||
</Badge>
|
||||
{task.tags?.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||
@@ -74,6 +71,17 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -mt-1 -mr-1 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
aria-label={"Edit " + task.title}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -81,6 +89,15 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnDroppable({ id, className, children }: { id: string; className?: string; children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div ref={setNodeRef} className={cn(className, isOver && "ring-2 ring-primary/40 bg-primary/10")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState(task?.title || "");
|
||||
@@ -88,6 +105,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
const [status, setStatus] = useState(task?.status || "todo");
|
||||
const [priority, setPriority] = useState(task?.priority || "medium");
|
||||
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
||||
@@ -110,6 +128,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
if (!title.trim()) return;
|
||||
const data: any = { title: title.trim(), description: description || null, status, priority };
|
||||
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
||||
const customFields = { ...customFieldValues };
|
||||
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
||||
if (task) {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
@@ -130,7 +150,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">Todo</SelectItem>
|
||||
@@ -142,7 +162,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
|
||||
<SelectTrigger id="priority"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
@@ -157,6 +177,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
<CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
@@ -180,9 +201,11 @@ function TasksPage() {
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: tasksData, isLoading } = useApiQuery<PaginatedResponse<Task>>(
|
||||
["tasks", search, statusFilter],
|
||||
"/tasks?" + new URLSearchParams({ limit: "200", ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
|
||||
["tasks", activeDomainId, search, statusFilter],
|
||||
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
|
||||
);
|
||||
|
||||
const tasks = tasksData?.items || [];
|
||||
@@ -193,6 +216,20 @@ function TasksPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
|
||||
api.post("/tasks/reorder", { orderedIds }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -218,14 +255,78 @@ function TasksPage() {
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const targetColumn = over.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
if (STATUS_COLUMNS.some((c) => c.id === targetColumn)) {
|
||||
const draggedTask = tasks.find((t) => t.id === taskId);
|
||||
if (!draggedTask) return;
|
||||
|
||||
// Tasks of a column in persisted order
|
||||
const columnTasks = (status: string) =>
|
||||
tasks
|
||||
.filter((t) => t.status === status)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
// Decide the target column and insertion index:
|
||||
// - over a column id => drop at the end of that column (handles empty columns)
|
||||
// - over a task id => drop at that task's position within its column
|
||||
let targetColumn: string;
|
||||
let insertIndex: number;
|
||||
if (STATUS_COLUMNS.some((c) => c.id === overId)) {
|
||||
targetColumn = overId;
|
||||
insertIndex = -1;
|
||||
} else {
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (!overTask) return;
|
||||
targetColumn = overTask.status;
|
||||
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
||||
insertIndex = overIndex === -1 ? -1 : overIndex;
|
||||
}
|
||||
|
||||
// Build the new ordered id list for the target column
|
||||
const targetIds = columnTasks(targetColumn)
|
||||
.map((t) => t.id)
|
||||
.filter((id) => id !== taskId);
|
||||
if (insertIndex === -1) {
|
||||
targetIds.push(taskId);
|
||||
} else {
|
||||
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
|
||||
}
|
||||
|
||||
// No-op when the task is already in that exact spot
|
||||
const currentIds = columnTasks(targetColumn).map((t) => t.id);
|
||||
const unchanged =
|
||||
currentIds.length === targetIds.length &&
|
||||
currentIds.every((id, i) => id === targetIds[i]);
|
||||
if (unchanged) return;
|
||||
|
||||
// Optimistic local update so the board reorders immediately
|
||||
const statusChanged = draggedTask.status !== targetColumn;
|
||||
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
||||
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((t) => {
|
||||
if (t.id === taskId && statusChanged) {
|
||||
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
|
||||
}
|
||||
const order = orderById.get(t.id);
|
||||
return order !== undefined ? { ...t, order } : t;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
if (statusChanged) {
|
||||
statusMutation.mutate({ id: taskId, status: targetColumn });
|
||||
}
|
||||
reorderMutation.mutate({ orderedIds: targetIds });
|
||||
};
|
||||
|
||||
const openTaskDetail = (task: Task) => {
|
||||
navigate({ to: "/tasks/$id", params: { id: task.id } });
|
||||
};
|
||||
|
||||
const openTaskPanel = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setPanelOpen(true);
|
||||
};
|
||||
@@ -233,7 +334,10 @@ function TasksPage() {
|
||||
const columns = useMemo(() => {
|
||||
return STATUS_COLUMNS.map((col) => ({
|
||||
...col,
|
||||
tasks: tasks.filter((t) => t.status === col.id),
|
||||
color: TASK_STATUS[col.id].dot,
|
||||
tasks: tasks
|
||||
.filter((t) => t.status === col.id)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
}));
|
||||
}, [tasks]);
|
||||
|
||||
@@ -280,12 +384,14 @@ function TasksPage() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading tasks...</div>
|
||||
<LoadingState label="Loading tasks..." />
|
||||
) : isError ? (
|
||||
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
||||
) : view === "board" ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{columns.map((col) => (
|
||||
<div key={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", col.color)} />
|
||||
@@ -296,14 +402,14 @@ function TasksPage() {
|
||||
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 min-h-[100px]">
|
||||
{col.tasks.map((task) => (
|
||||
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} />
|
||||
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} onEdit={() => openTaskPanel(task)} />
|
||||
))}
|
||||
{col.tasks.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</ColumnDroppable>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
@@ -325,16 +431,18 @@ function TasksPage() {
|
||||
<TableBody>
|
||||
{tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No tasks found</TableCell>
|
||||
<TableCell colSpan={5}>
|
||||
<EmptyState title="No tasks found" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : tasks.map((task) => (
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
||||
<TableCell className="font-medium">{task.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-[10px]">{task.status.replace("_", " ")}</Badge>
|
||||
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>{task.priority}</Badge>
|
||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
||||
@@ -345,7 +453,7 @@ function TasksPage() {
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -5,23 +5,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TagManager } from "@/components/entities/tag-manager";
|
||||
import { CustomFieldsDisplay } from "@/components/custom-fields/custom-fields-display";
|
||||
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
|
||||
import type { Task } from "@/lib/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
todo: "bg-slate-500",
|
||||
in_progress: "bg-blue-500",
|
||||
done: "bg-green-500",
|
||||
cancelled: "bg-red-500",
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
low: "bg-slate-400",
|
||||
medium: "bg-amber-500",
|
||||
high: "bg-orange-500",
|
||||
urgent: "bg-red-500",
|
||||
};
|
||||
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||
|
||||
function TaskDetail() {
|
||||
const { id } = useParams({ from: Route.id });
|
||||
@@ -39,14 +28,19 @@ function TaskDetail() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<ListTodo className="h-6 w-6 text-muted-foreground" />
|
||||
<CardTitle className="text-2xl">{task.title}</CardTitle>
|
||||
<Badge className={STATUS_COLORS[task.status] || "bg-slate-500"}>{task.status.replace("_", " ")}</Badge>
|
||||
<Badge variant="outline" className={PRIORITY_COLORS[task.priority]}>
|
||||
{task.priority}
|
||||
<Badge className={TASK_STATUS[task.status]?.badge}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
|
||||
<Badge variant="outline" className={PRIORITY[task.priority]?.badge}>
|
||||
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</div>
|
||||
{task.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||
@@ -72,16 +66,8 @@ function TaskDetail() {
|
||||
<span>Status: {task.status.replace("_", " ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.tags.map((t: any) => (
|
||||
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
|
||||
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/credentials", {
|
||||
method: "POST",
|
||||
@@ -25,46 +33,80 @@ function LoginPage() {
|
||||
navigate({ to: "/" });
|
||||
} catch {
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm p-8 space-y-4 border rounded-lg"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-center">Sign In</h1>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-background"
|
||||
required
|
||||
/>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-3 text-center">
|
||||
<span
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl shadow-md"
|
||||
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
||||
>
|
||||
<Sparkles className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Project E</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sign in to your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-background"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:opacity-90"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -6,6 +6,9 @@ export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
|
||||
Reference in New Issue
Block a user