feat: Implement analysis job tracking with progress timeline and enhanced data source status management.

This commit is contained in:
2026-02-03 22:43:27 +00:00
parent c47614bc66
commit 358f2a42dd
22 changed files with 2251 additions and 219 deletions

View File

@@ -0,0 +1,82 @@
"use client"
import { CheckCircle2, AlertTriangle, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
type TimelineItem = {
key: string
label: string
status: "pending" | "running" | "completed" | "failed"
detail?: string
}
function StatusIcon({ status }: { status: TimelineItem["status"] }) {
if (status === "completed") {
return <CheckCircle2 className="h-4 w-4 text-foreground" />
}
if (status === "failed") {
return <AlertTriangle className="h-4 w-4 text-destructive" />
}
if (status === "running") {
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
}
return <span className="h-2.5 w-2.5 rounded-full border border-muted-foreground/60" />
}
export function AnalysisTimeline({ items }: { items: TimelineItem[] }) {
if (!items.length) return null
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Analysis timeline
</div>
<div className="mt-3 space-y-3">
{items.map((item, index) => {
const isPending = item.status === "pending"
const nextStatus = items[index + 1]?.status
const isStrongLine =
nextStatus &&
(item.status === "completed" || item.status === "running") &&
(nextStatus === "completed" || nextStatus === "running")
return (
<div key={item.key} className="relative pl-6">
<span
className={cn(
"absolute left-[6px] top-3 bottom-[-12px] w-px",
index === items.length - 1 ? "hidden" : "bg-border/40",
isStrongLine && "bg-foreground/60"
)}
/>
<div
className={cn(
"flex items-start gap-3 text-sm transition",
isPending && "scale-[0.96] opacity-60"
)}
>
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background shadow-sm">
<StatusIcon status={item.status} />
</div>
<div className="min-w-0">
<div
className={cn(
"font-medium",
item.status === "failed" && "text-destructive"
)}
>
{item.label}
</div>
{item.detail && (
<div className="text-xs text-muted-foreground">
{item.detail}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -7,10 +7,13 @@ import {
Command,
Frame,
HelpCircle,
Settings,
Settings2,
Terminal,
Target,
Plus
Plus,
ArrowUpRight,
ChevronsUpDown
} from "lucide-react"
import { NavUser } from "@/components/nav-user"
@@ -26,6 +29,14 @@ import {
SidebarGroupLabel,
SidebarGroupContent,
} from "@/components/ui/sidebar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useQuery, useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
import { Checkbox } from "@/components/ui/checkbox"
@@ -34,19 +45,49 @@ import { useProject } from "@/components/project-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { AnalysisTimeline } from "@/components/analysis-timeline"
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const projects = useQuery(api.projects.getProjects);
const currentUser = useQuery(api.users.getCurrent);
const { selectedProjectId, setSelectedProjectId } = useProject();
const addDataSource = useMutation(api.dataSources.addDataSource);
const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus);
const createAnalysis = useMutation(api.analyses.createAnalysis);
const createAnalysisJob = useMutation(api.analysisJobs.create);
const updateAnalysisJob = useMutation(api.analysisJobs.update);
const [isAdding, setIsAdding] = React.useState(false);
const [isCreatingProject, setIsCreatingProject] = React.useState(false);
const [projectName, setProjectName] = React.useState("");
const [projectDefault, setProjectDefault] = React.useState(true);
const [projectError, setProjectError] = React.useState<string | null>(null);
const [isSubmittingProject, setIsSubmittingProject] = React.useState(false);
const createProject = useMutation(api.projects.createProject);
const updateProject = useMutation(api.projects.updateProject);
const [isEditingProject, setIsEditingProject] = React.useState(false);
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = React.useState("");
const [editingProjectDefault, setEditingProjectDefault] = React.useState(false);
const [editingProjectError, setEditingProjectError] = React.useState<string | null>(null);
const [isSubmittingEdit, setIsSubmittingEdit] = React.useState(false);
const [sourceUrl, setSourceUrl] = React.useState("");
const [sourceName, setSourceName] = React.useState("");
const [sourceError, setSourceError] = React.useState<string | null>(null);
const [sourceNotice, setSourceNotice] = React.useState<string | null>(null);
const [isSubmittingSource, setIsSubmittingSource] = React.useState(false);
const [manualMode, setManualMode] = React.useState(false);
const [manualProductName, setManualProductName] = React.useState("");
const [manualDescription, setManualDescription] = React.useState("");
const [manualFeatures, setManualFeatures] = React.useState("");
const [pendingSourceId, setPendingSourceId] = React.useState<string | null>(null);
const [pendingProjectId, setPendingProjectId] = React.useState<string | null>(null);
const [pendingJobId, setPendingJobId] = React.useState<string | null>(null);
const analysisJob = useQuery(
api.analysisJobs.getById,
pendingJobId ? { jobId: pendingJobId as any } : "skip"
);
// Set default selected project
React.useEffect(() => {
@@ -67,6 +108,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const selectedProject = projects?.find(p => p._id === selectedProjectId);
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
const selectedProjectName = selectedProject?.name || "Select Project";
const handleToggle = async (sourceId: string, checked: boolean) => {
if (!selectedProjectId) return;
@@ -87,15 +129,27 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
setIsSubmittingSource(true);
try {
const { sourceId, projectId } = await addDataSource({
const result = await addDataSource({
projectId: selectedProjectId as any,
url: sourceUrl,
name: sourceName || sourceUrl,
type: "website",
});
if ((result as any).isExisting) {
setSourceNotice("This source already exists and was reused.");
}
const jobId = await createAnalysisJob({
projectId: result.projectId,
dataSourceId: result.sourceId,
});
setPendingSourceId(result.sourceId);
setPendingProjectId(result.projectId);
setPendingJobId(jobId);
await updateDataSourceStatus({
dataSourceId: sourceId,
dataSourceId: result.sourceId,
analysisStatus: "pending",
lastError: undefined,
lastAnalyzedAt: undefined,
@@ -104,14 +158,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const response = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: sourceUrl }),
body: JSON.stringify({ url: sourceUrl, jobId }),
});
const data = await response.json();
if (!response.ok) {
if (data.needsManualInput) {
setManualMode(true);
setManualProductName(
sourceName || sourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")
);
setPendingSourceId(result.sourceId);
setPendingProjectId(result.projectId);
setSourceError(data.error || "Manual input required.");
return;
}
await updateDataSourceStatus({
dataSourceId: sourceId,
dataSourceId: result.sourceId,
analysisStatus: "failed",
lastError: data.error || "Analysis failed",
lastAnalyzedAt: Date.now(),
@@ -120,13 +185,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
await createAnalysis({
projectId,
dataSourceId: sourceId,
projectId: result.projectId,
dataSourceId: result.sourceId,
analysis: data.data,
});
await updateDataSourceStatus({
dataSourceId: sourceId,
dataSourceId: result.sourceId,
analysisStatus: "completed",
lastError: undefined,
lastAnalyzedAt: Date.now(),
@@ -134,6 +199,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
setSourceUrl("");
setSourceName("");
setSourceNotice(null);
setManualMode(false);
setManualProductName("");
setManualDescription("");
setManualFeatures("");
setPendingSourceId(null);
setPendingProjectId(null);
setPendingJobId(null);
setIsAdding(false);
} catch (err: any) {
setSourceError(err?.message || "Failed to add source.");
@@ -142,22 +215,138 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
};
const handleManualAnalyze = async () => {
if (!manualProductName || !manualDescription) {
setSourceError("Product name and description are required.");
return;
}
if (!pendingSourceId || !pendingProjectId) {
setSourceError("Missing pending source.");
return;
}
setIsSubmittingSource(true);
setSourceError(null);
try {
const response = await fetch("/api/analyze-manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
productName: manualProductName,
description: manualDescription,
features: manualFeatures,
jobId: pendingJobId || undefined,
}),
});
const data = await response.json();
if (!response.ok) {
await updateDataSourceStatus({
dataSourceId: pendingSourceId as any,
analysisStatus: "failed",
lastError: data.error || "Manual analysis failed",
lastAnalyzedAt: Date.now(),
});
throw new Error(data.error || "Manual analysis failed");
}
await createAnalysis({
projectId: pendingProjectId as any,
dataSourceId: pendingSourceId as any,
analysis: data.data,
});
await updateDataSourceStatus({
dataSourceId: pendingSourceId as any,
analysisStatus: "completed",
lastError: undefined,
lastAnalyzedAt: Date.now(),
});
setSourceUrl("");
setSourceName("");
setManualMode(false);
setManualProductName("");
setManualDescription("");
setManualFeatures("");
setPendingSourceId(null);
setPendingProjectId(null);
setPendingJobId(null);
setIsAdding(false);
} catch (err: any) {
setSourceError(err?.message || "Manual analysis failed.");
} finally {
setIsSubmittingSource(false);
}
};
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold uppercase">Sanati</span>
<span className="truncate text-xs">Pro</span>
</div>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{selectedProjectName}</span>
<span className="truncate text-xs text-muted-foreground">Projects</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-64">
<DropdownMenuLabel>Switch project</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects?.map((project) => (
<DropdownMenuItem
key={project._id}
className="justify-between"
onSelect={(event) => {
if (event.defaultPrevented) return
setSelectedProjectId(project._id)
}}
>
<div className="flex items-center gap-2">
<Frame className="text-muted-foreground" />
<span className="truncate">{project.name}</span>
</div>
<button
type="button"
data-project-settings
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation();
setEditingProjectId(project._id);
setEditingProjectName(project.name);
setEditingProjectDefault(project.isDefault);
setEditingProjectError(null);
setIsEditingProject(true);
}}
aria-label={`Project settings for ${project.name}`}
>
<Settings className="size-4" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setIsCreatingProject(true)}>
<Plus />
Create Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
@@ -219,32 +408,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroupContent>
</SidebarGroup>
{/* Projects (Simple List for now, can be switcher) */}
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{projects?.map((project) => (
<SidebarMenuItem key={project._id}>
<SidebarMenuButton
onClick={() => setSelectedProjectId(project._id)}
isActive={selectedProjectId === project._id}
>
<Frame className="text-muted-foreground" />
<span>{project.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-muted-foreground">
<Plus />
<span>Create Project</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Data Sources Config */}
{selectedProjectId && (
<SidebarGroup>
@@ -258,15 +421,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="text-sm text-muted-foreground pl-2">No data sources yet.</div>
)}
{dataSources?.map((source) => (
<div key={source._id} className="flex items-center space-x-2">
<Checkbox
id={source._id}
checked={selectedSourceIds.includes(source._id)}
onCheckedChange={(checked) => handleToggle(source._id, checked === true)}
/>
<Label htmlFor={source._id} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 truncate cursor-pointer">
{source.name || source.url}
</Label>
<div
key={source._id}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-muted/40"
>
<div className="flex min-w-0 items-center gap-2">
<Checkbox
id={source._id}
checked={selectedSourceIds.includes(source._id)}
onCheckedChange={(checked) => handleToggle(source._id, checked === true)}
/>
<Label
htmlFor={source._id}
className="truncate text-sm font-medium leading-none cursor-pointer"
>
{source.name || source.url}
</Label>
</div>
<Link
href={`/data-sources/${source._id}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Details
<ArrowUpRight className="size-3" />
</Link>
</div>
))}
<Button
@@ -282,13 +460,45 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)}
</SidebarContent>
<SidebarFooter>
<NavUser user={{
name: "User",
email: "user@example.com",
avatar: ""
}} />
{currentUser && (currentUser.name || currentUser.email) && (
<NavUser
user={{
name: currentUser.name || currentUser.email || "",
email: currentUser.email || "",
avatar: currentUser.image || "",
}}
/>
)}
</SidebarFooter>
<Dialog open={isAdding} onOpenChange={setIsAdding}>
<Dialog
open={isAdding}
onOpenChange={(open) => {
if (!open && manualMode && pendingSourceId) {
updateDataSourceStatus({
dataSourceId: pendingSourceId as any,
analysisStatus: "failed",
lastError: "Manual input cancelled",
lastAnalyzedAt: Date.now(),
});
}
if (!open && manualMode && pendingJobId) {
updateAnalysisJob({
jobId: pendingJobId as any,
status: "failed",
error: "Manual input cancelled",
});
}
if (!open) {
setManualMode(false);
setSourceError(null);
setSourceNotice(null);
setPendingSourceId(null);
setPendingProjectId(null);
setPendingJobId(null);
}
setIsAdding(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Data Source</DialogTitle>
@@ -297,35 +507,224 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="sourceUrl">Website URL</Label>
<Input
id="sourceUrl"
placeholder="https://example.com"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
disabled={isSubmittingSource}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sourceName">Name (optional)</Label>
<Input
id="sourceName"
placeholder="Product name"
value={sourceName}
onChange={(event) => setSourceName(event.target.value)}
disabled={isSubmittingSource}
/>
</div>
{!manualMode && (
<>
<div className="space-y-2">
<Label htmlFor="sourceUrl">Website URL</Label>
<Input
id="sourceUrl"
placeholder="https://example.com"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
disabled={isSubmittingSource}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sourceName">Name (optional)</Label>
<Input
id="sourceName"
placeholder="Product name"
value={sourceName}
onChange={(event) => setSourceName(event.target.value)}
disabled={isSubmittingSource}
/>
</div>
</>
)}
{manualMode && (
<>
<div className="space-y-2">
<Label htmlFor="manualProductName">Product Name</Label>
<Input
id="manualProductName"
value={manualProductName}
onChange={(event) => setManualProductName(event.target.value)}
disabled={isSubmittingSource}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manualDescription">Description</Label>
<Textarea
id="manualDescription"
value={manualDescription}
onChange={(event) => setManualDescription(event.target.value)}
disabled={isSubmittingSource}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manualFeatures">Key Features (one per line)</Label>
<Textarea
id="manualFeatures"
value={manualFeatures}
onChange={(event) => setManualFeatures(event.target.value)}
disabled={isSubmittingSource}
rows={3}
/>
</div>
</>
)}
{sourceNotice && (
<div className="text-sm text-muted-foreground">{sourceNotice}</div>
)}
{sourceError && (
<div className="text-sm text-destructive">{sourceError}</div>
)}
{analysisJob?.timeline?.length ? (
<AnalysisTimeline items={analysisJob.timeline} />
) : null}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsAdding(false)} disabled={isSubmittingSource}>
<Button
variant="outline"
onClick={() => setIsAdding(false)}
disabled={isSubmittingSource}
>
Cancel
</Button>
<Button onClick={handleAddSource} disabled={isSubmittingSource}>
{isSubmittingSource ? "Analyzing..." : "Add Source"}
{manualMode ? (
<Button onClick={handleManualAnalyze} disabled={isSubmittingSource}>
{isSubmittingSource ? "Analyzing..." : "Analyze Manually"}
</Button>
) : (
<Button onClick={handleAddSource} disabled={isSubmittingSource}>
{isSubmittingSource ? "Analyzing..." : "Add Source"}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isEditingProject} onOpenChange={setIsEditingProject}>
<DialogContent>
<DialogHeader>
<DialogTitle>Project Settings</DialogTitle>
<DialogDescription>
Update the project name and default status.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="editProjectName">Project Name</Label>
<Input
id="editProjectName"
value={editingProjectName}
onChange={(event) => setEditingProjectName(event.target.value)}
disabled={isSubmittingEdit}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="editProjectDefault"
checked={editingProjectDefault}
onCheckedChange={(checked) => setEditingProjectDefault(checked === true)}
disabled={isSubmittingEdit}
/>
<Label htmlFor="editProjectDefault">Set as default</Label>
</div>
{editingProjectError && (
<div className="text-sm text-destructive">{editingProjectError}</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsEditingProject(false)}
disabled={isSubmittingEdit}
>
Cancel
</Button>
<Button
onClick={async () => {
if (!editingProjectId) return;
if (!editingProjectName.trim()) {
setEditingProjectError("Project name is required.");
return;
}
setIsSubmittingEdit(true);
setEditingProjectError(null);
try {
await updateProject({
projectId: editingProjectId as any,
name: editingProjectName.trim(),
isDefault: editingProjectDefault,
});
setIsEditingProject(false);
} catch (err: any) {
setEditingProjectError(err?.message || "Failed to update project.");
} finally {
setIsSubmittingEdit(false);
}
}}
disabled={isSubmittingEdit}
>
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isCreatingProject} onOpenChange={setIsCreatingProject}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Add a new project for a separate product or workflow.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="projectName">Project Name</Label>
<Input
id="projectName"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
disabled={isSubmittingProject}
/>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
id="projectDefault"
checked={projectDefault}
onCheckedChange={(checked) => setProjectDefault(checked === true)}
/>
<Label htmlFor="projectDefault">Make this the default project</Label>
</div>
{projectError && (
<div className="text-sm text-destructive">{projectError}</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsCreatingProject(false)}
disabled={isSubmittingProject}
>
Cancel
</Button>
<Button
onClick={async () => {
if (!projectName.trim()) {
setProjectError("Project name is required.");
return;
}
setProjectError(null);
setIsSubmittingProject(true);
try {
const projectId = await createProject({
name: projectName.trim(),
isDefault: projectDefault,
});
setSelectedProjectId(projectId as any);
setProjectName("");
setProjectDefault(true);
setIsCreatingProject(false);
} catch (err: any) {
setProjectError(err?.message || "Failed to create project.");
} finally {
setIsSubmittingProject(false);
}
}}
disabled={isSubmittingProject}
>
{isSubmittingProject ? "Creating..." : "Create Project"}
</Button>
</div>
</div>

View File

@@ -16,8 +16,10 @@ export function HeroShader() {
let animationFrameId: number;
const resize = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
@@ -36,15 +38,25 @@ export function HeroShader() {
ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; // stroke(w, 116) approx white with alpha
// Model space tuned for ~400x400 tweetcart output.
const baseSize = 400;
const scale = Math.min(canvas.width, canvas.height) / baseSize;
const canvasWidth = canvas.offsetWidth;
const canvasHeight = canvas.offsetHeight;
// Center of drawing - positioned to the right and aligned with content
const cx = canvas.width * 0.7;
const cy = canvas.height * 0.4;
const cx = canvasWidth * 0.7;
const cy = canvasHeight * 0.52;
// Loop for points
// for(t+=PI/90,i=1e4;i--;)a()
t += Math.PI / 90;
for (let i = 10000; i > 0; i--) {
const density = Math.max(1, scale);
const pointCount = Math.min(18000, Math.floor(10000 * density));
for (let i = pointCount; i > 0; i--) {
// y = i / 790
let y = i / 790;
@@ -87,17 +99,17 @@ export function HeroShader() {
const c = d / 4 - t / 2 + (i % 2) * 3;
// q = y * k / 5 * (2 + sin(d*2 + y - t*4)) + 80
const q = y * k / 5 * (2 + Math.sin(d * 2 + y - t * 4)) + 300;
const q = y * k / 5 * (2 + Math.sin(d * 2 + y - t * 4)) + 80;
// x = q * cos(c) + 200
// y_out = q * sin(c) + d * 9 + 60
// 200 and 60 are likely offsets for 400x400 canvas.
// We should center it.
// Original offsets assume a 400x400 canvas; map from model space to screen space.
const modelX = q * Math.cos(c) + 200;
const modelY = q * Math.sin(c) + d * 9 + 60;
const x = (q * Math.cos(c)) + cx;
const y_out = (q * Math.sin(c) + d * 9) + cy;
const x = cx + (modelX - 200) * scale;
const y_out = cy + (modelY - 200) * scale;
const pointSize = Math.min(2 * scale, 3.5);
ctx.fillRect(x, y_out, 2.5, 2.5);
ctx.fillRect(x, y_out, pointSize, pointSize);
}
animationFrameId = requestAnimationFrame(draw);

View File

@@ -1,5 +1,7 @@
"use client"
import * as React from "react"
import {
BadgeCheck,
Bell,
@@ -42,6 +44,19 @@ export function NavUser({
}) {
const { isMobile } = useSidebar()
const { signOut } = useAuthActions()
const seed = React.useMemo(() => {
const base = user.email || user.name || "";
return base.trim() || "user";
}, [user.email, user.name]);
const avatarUrl = user.avatar || `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(seed)}`;
const fallbackText = React.useMemo(() => {
const base = user.name || user.email || "";
const trimmed = base.trim();
if (!trimmed) return "";
const parts = trimmed.split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}, [user.name, user.email])
return (
<SidebarMenu>
@@ -53,8 +68,8 @@ export function NavUser({
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={avatarUrl} alt={user.name} />
<AvatarFallback className="rounded-lg">{fallbackText}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
@@ -72,8 +87,8 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={avatarUrl} alt={user.name} />
<AvatarFallback className="rounded-lg">{fallbackText}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
value?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${Math.min(Math.max(value, 0), 100)}%` }}
/>
</div>
)
)
Progress.displayName = "Progress"
export { Progress }