Add OpenMemory (#2676)
Co-authored-by: Saket Aryan <94069182+whysosaket@users.noreply.github.com> Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
This commit is contained in:
166
openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx
Normal file
166
openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PauseIcon, Loader2, PlayIcon } from "lucide-react";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import Image from "next/image";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { setAppDetails } from "@/store/appsSlice";
|
||||
import { BiEdit } from "react-icons/bi";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { RootState } from "@/store/store";
|
||||
|
||||
const capitalize = (str: string) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
const AppDetailCard = ({
|
||||
appId,
|
||||
selectedApp,
|
||||
}: {
|
||||
appId: string;
|
||||
selectedApp: any;
|
||||
}) => {
|
||||
const { updateAppDetails } = useAppsApi();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const apps = useSelector((state: RootState) => state.apps.apps);
|
||||
const currentApp = apps.find((app: any) => app.id === appId);
|
||||
const appConfig = currentApp
|
||||
? constants[currentApp.name as keyof typeof constants] || constants.default
|
||||
: constants.default;
|
||||
|
||||
const handlePauseAccess = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateAppDetails(appId, {
|
||||
is_active: !selectedApp.details.is_active,
|
||||
});
|
||||
dispatch(
|
||||
setAppDetails({ appId, isActive: !selectedApp.details.is_active })
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle app pause state:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonText = selectedApp.details.is_active
|
||||
? "Pause Access"
|
||||
: "Unpause Access";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-zinc-900 border w-[320px] border-zinc-800 rounded-xl mb-6">
|
||||
<div className="flex items-center gap-2 mb-4 bg-zinc-800 rounded-t-xl p-3">
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
{appConfig.iconImage ? (
|
||||
<div>
|
||||
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={appConfig.iconImage}
|
||||
alt={appConfig.name}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-zinc-700 rounded-full">
|
||||
<BiEdit className="w-4 h-4 text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-md font-semibold">{appConfig.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-3">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Access Status</p>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
selectedApp.details.is_active
|
||||
? "text-emerald-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{capitalize(
|
||||
selectedApp.details.is_active ? "active" : "inactive"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Total Memories Created</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.total_memories_created} Memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Total Memories Accessed</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.total_memories_accessed} Memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">First Accessed</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.first_accessed
|
||||
? new Date(
|
||||
selectedApp.details.first_accessed
|
||||
).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Last Accessed</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.last_accessed
|
||||
? new Date(
|
||||
selectedApp.details.last_accessed
|
||||
).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
onClick={handlePauseAccess}
|
||||
className="flex bg-transparent w-[170px] bg-zinc-800 border-zinc-800 hover:bg-zinc-800 text-white"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : buttonText === "Pause Access" ? (
|
||||
<PauseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
)}
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDetailCard;
|
||||
115
openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx
Normal file
115
openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import Link from "next/link";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import Image from "next/image";
|
||||
interface MemoryCardProps {
|
||||
id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, any>;
|
||||
categories?: string[];
|
||||
access_count?: number;
|
||||
app_name: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export function MemoryCard({
|
||||
id,
|
||||
content,
|
||||
created_at,
|
||||
metadata,
|
||||
categories,
|
||||
access_count,
|
||||
app_name,
|
||||
state,
|
||||
}: MemoryCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="border-l-2 border-primary pl-4 mb-4">
|
||||
<p
|
||||
className={`${state !== "active" ? "text-zinc-400" : "text-white"}`}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{metadata && Object.keys(metadata).length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-zinc-500 uppercase mb-2">METADATA</p>
|
||||
<div className="bg-zinc-800 rounded p-3 text-zinc-400">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2">
|
||||
<Categories
|
||||
categories={categories as any}
|
||||
isPaused={state !== "active"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400 text-sm">
|
||||
{access_count ? (
|
||||
<span className="relative top-1">
|
||||
Accessed {access_count} times
|
||||
</span>
|
||||
) : (
|
||||
new Date(created_at + "Z").toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
|
||||
{state !== "active" && (
|
||||
<span className="inline-block px-3 border border-yellow-600 text-yellow-600 font-semibold text-xs rounded-full bg-yellow-400/10 backdrop-blur-sm">
|
||||
{state === "paused" ? "Paused" : "Archived"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!app_name && (
|
||||
<Link
|
||||
href={`/memory/${id}`}
|
||||
className="hover:cursor-pointer bg-zinc-800 hover:bg-zinc-700 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:text-white"
|
||||
>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{app_name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-zinc-700 px-3 py-1 rounded-lg">
|
||||
<span className="text-sm text-zinc-400">Created by:</span>
|
||||
<div className="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
constants[app_name as keyof typeof constants]
|
||||
?.iconImage || ""
|
||||
}
|
||||
alt="OpenMemory"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-100 font-semibold">
|
||||
{constants[app_name as keyof typeof constants]?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
openmemory/ui/app/apps/[appId]/page.tsx
Normal file
219
openmemory/ui/app/apps/[appId]/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MemoryCard } from "./components/MemoryCard";
|
||||
import AppDetailCard from "./components/AppDetailCard";
|
||||
import "@/styles/animation.css";
|
||||
import NotFound from "@/app/not-found";
|
||||
import { AppDetailCardSkeleton } from "@/skeleton/AppDetailCardSkeleton";
|
||||
import { MemoryCardSkeleton } from "@/skeleton/MemoryCardSkeleton";
|
||||
|
||||
export default function AppDetailsPage() {
|
||||
const params = useParams();
|
||||
const appId = params.appId as string;
|
||||
const [activeTab, setActiveTab] = useState("created");
|
||||
|
||||
const {
|
||||
fetchAppDetails,
|
||||
fetchAppMemories,
|
||||
fetchAppAccessedMemories,
|
||||
fetchApps,
|
||||
} = useAppsApi();
|
||||
const selectedApp = useSelector((state: RootState) => state.apps.selectedApp);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApps({});
|
||||
}, [fetchApps]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (appId) {
|
||||
try {
|
||||
// Load all data in parallel
|
||||
await Promise.all([
|
||||
fetchAppDetails(appId),
|
||||
fetchAppMemories(appId),
|
||||
fetchAppAccessedMemories(appId),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error loading app data:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [appId, fetchAppDetails, fetchAppMemories, fetchAppAccessedMemories]);
|
||||
|
||||
if (selectedApp.error) {
|
||||
return (
|
||||
<NotFound message={selectedApp.error} title="Error loading app details" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedApp.details) {
|
||||
return (
|
||||
<div className="flex-1 py-6 text-white">
|
||||
<div className="container flex justify-between">
|
||||
<div className="flex-1 p-4 max-w-4xl animate-fade-slide-down">
|
||||
<div className="mb-6">
|
||||
<div className="h-10 w-64 bg-zinc-800 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<MemoryCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-14 animate-fade-slide-down delay-2">
|
||||
<AppDetailCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCreatedMemories = () => {
|
||||
const memories = selectedApp.memories.created;
|
||||
|
||||
if (memories.loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<MemoryCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.error) {
|
||||
return (
|
||||
<NotFound message={memories.error} title="Error loading memories" />
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.items.length === 0) {
|
||||
return (
|
||||
<div className="text-zinc-400 text-center py-8">No memories found</div>
|
||||
);
|
||||
}
|
||||
|
||||
return memories.items.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id + memory.created_at}
|
||||
id={memory.id}
|
||||
content={memory.content}
|
||||
created_at={memory.created_at}
|
||||
metadata={memory.metadata_}
|
||||
categories={memory.categories}
|
||||
app_name={memory.app_name}
|
||||
state={memory.state}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const renderAccessedMemories = () => {
|
||||
const memories = selectedApp.memories.accessed;
|
||||
|
||||
if (memories.loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<MemoryCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.error) {
|
||||
return (
|
||||
<div className="text-red-400 bg-red-400/10 p-4 rounded-lg">
|
||||
Error loading memories: {memories.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.items.length === 0) {
|
||||
return (
|
||||
<div className="text-zinc-400 text-center py-8">
|
||||
No accessed memories found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return memories.items.map((accessedMemory) => (
|
||||
<div
|
||||
key={accessedMemory.memory.id + accessedMemory.memory.created_at}
|
||||
className="relative"
|
||||
>
|
||||
<MemoryCard
|
||||
id={accessedMemory.memory.id}
|
||||
content={accessedMemory.memory.content}
|
||||
created_at={accessedMemory.memory.created_at}
|
||||
metadata={accessedMemory.memory.metadata_}
|
||||
categories={accessedMemory.memory.categories}
|
||||
access_count={accessedMemory.access_count}
|
||||
app_name={accessedMemory.memory.app_name}
|
||||
state={accessedMemory.memory.state}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 py-6 text-white">
|
||||
<div className="container flex justify-between">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 p-4 max-w-4xl animate-fade-slide-down">
|
||||
<Tabs
|
||||
defaultValue="created"
|
||||
className="mb-6"
|
||||
onValueChange={setActiveTab}
|
||||
>
|
||||
<TabsList className="bg-transparent border-b border-zinc-800 rounded-none w-full justify-start gap-8 p-0">
|
||||
<TabsTrigger
|
||||
value="created"
|
||||
className={`px-0 pb-2 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none ${
|
||||
activeTab === "created" ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
Created ({selectedApp.memories.created.total})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="accessed"
|
||||
className={`px-0 pb-2 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none ${
|
||||
activeTab === "accessed" ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
Accessed ({selectedApp.memories.accessed.total})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="created"
|
||||
className="mt-6 space-y-6 animate-fade-slide-down delay-1"
|
||||
>
|
||||
{renderCreatedMemories()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="accessed"
|
||||
className="mt-6 space-y-6 animate-fade-slide-down delay-1"
|
||||
>
|
||||
{renderAccessedMemories()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="p-14 animate-fade-slide-down delay-2">
|
||||
<AppDetailCard appId={appId} selectedApp={selectedApp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
openmemory/ui/app/apps/components/AppCard.tsx
Normal file
84
openmemory/ui/app/apps/components/AppCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type React from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { App } from "@/store/appsSlice";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface AppCardProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export function AppCard({ app }: AppCardProps) {
|
||||
const router = useRouter();
|
||||
const appConfig =
|
||||
constants[app.name as keyof typeof constants] || constants.default;
|
||||
const isActive = app.is_active;
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 text-white border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative z-10 rounded-full overflow-hidden bg-[#2a2a2a] w-6 h-6 flex items-center justify-center flex-shrink-0">
|
||||
{appConfig.iconImage ? (
|
||||
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={appConfig.iconImage}
|
||||
alt={appConfig.name}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{appConfig.icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{appConfig.name}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4 my-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm mb-1">Memories Created</p>
|
||||
<p className="text-xl font-medium">
|
||||
{app.total_memories_created.toLocaleString()} Memories
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm mb-1">Memories Accessed</p>
|
||||
<p className="text-xl font-medium">
|
||||
{app.total_memories_accessed.toLocaleString()} Memories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t border-zinc-800 p-0 px-6 py-2 flex justify-between items-center">
|
||||
<div
|
||||
className={`${
|
||||
isActive
|
||||
? "bg-green-800 text-white hover:bg-green-500/20"
|
||||
: "bg-red-500/20 text-red-400 hover:bg-red-500/20"
|
||||
} rounded-lg px-2 py-0.5 flex items-center text-sm`}
|
||||
>
|
||||
<span className="h-2 w-2 my-auto mr-1 rounded-full inline-block bg-current"></span>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => router.push(`/apps/${app.id}`)}
|
||||
className="border hover:cursor-pointer border-zinc-700 bg-zinc-950 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:bg-zinc-950/50 hover:text-white"
|
||||
>
|
||||
View Details <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
150
openmemory/ui/app/apps/components/AppFilters.tsx
Normal file
150
openmemory/ui/app/apps/components/AppFilters.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search, ChevronDown, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
setSearchQuery,
|
||||
setActiveFilter,
|
||||
setSortBy,
|
||||
setSortDirection,
|
||||
} from "@/store/appsSlice";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useCallback } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { AppFiltersSkeleton } from "@/skeleton/AppFiltersSkeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "memories", label: "Memories Created" },
|
||||
{ value: "memories_accessed", label: "Memories Accessed" },
|
||||
];
|
||||
|
||||
export function AppFilters() {
|
||||
const dispatch = useDispatch();
|
||||
const filters = useSelector((state: RootState) => state.apps.filters);
|
||||
const [localSearch, setLocalSearch] = useState(filters.searchQuery);
|
||||
const { isLoading } = useAppsApi();
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((query: string) => {
|
||||
dispatch(setSearchQuery(query));
|
||||
}, 300),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setLocalSearch(query);
|
||||
debouncedSearch(query);
|
||||
};
|
||||
|
||||
const handleActiveFilterChange = (value: string) => {
|
||||
dispatch(setActiveFilter(value === "all" ? "all" : value === "true"));
|
||||
};
|
||||
|
||||
const setSorting = (sortBy: "name" | "memories" | "memories_accessed") => {
|
||||
const newDirection =
|
||||
filters.sortBy === sortBy && filters.sortDirection === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
dispatch(setSortBy(sortBy));
|
||||
dispatch(setSortDirection(newDirection));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearch(filters.searchQuery);
|
||||
}, [filters.searchQuery]);
|
||||
|
||||
if (isLoading) {
|
||||
return <AppFiltersSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search Apps..."
|
||||
className="pl-8 bg-zinc-950 border-zinc-800 max-w-[500px]"
|
||||
value={localSearch}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={String(filters.isActive)}
|
||||
onValueChange={handleActiveFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="true">Active</SelectItem>
|
||||
<SelectItem value="false">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 px-4 border-zinc-700 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
{filters.sortDirection === "asc" ? (
|
||||
<SortDesc className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<SortAsc className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Sort: {sortOptions.find((o) => o.value === filters.sortBy)?.label}
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-zinc-800" />
|
||||
<DropdownMenuGroup>
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
setSorting(
|
||||
option.value as "name" | "memories" | "memories_accessed"
|
||||
)
|
||||
}
|
||||
className="cursor-pointer flex justify-between items-center"
|
||||
>
|
||||
{option.label}
|
||||
{filters.sortBy === option.value &&
|
||||
(filters.sortDirection === "asc" ? (
|
||||
<SortAsc className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4 text-primary" />
|
||||
))}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
openmemory/ui/app/apps/components/AppGrid.tsx
Normal file
48
openmemory/ui/app/apps/components/AppGrid.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { AppCard } from "./AppCard";
|
||||
import { AppCardSkeleton } from "@/skeleton/AppCardSkeleton";
|
||||
|
||||
export function AppGrid() {
|
||||
const { fetchApps, isLoading } = useAppsApi();
|
||||
const apps = useSelector((state: RootState) => state.apps.apps);
|
||||
const filters = useSelector((state: RootState) => state.apps.filters);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApps({
|
||||
name: filters.searchQuery,
|
||||
is_active: filters.isActive === "all" ? undefined : filters.isActive,
|
||||
sort_by: filters.sortBy,
|
||||
sort_direction: filters.sortDirection,
|
||||
});
|
||||
}, [fetchApps, filters]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<AppCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-zinc-500 py-8">
|
||||
No apps found matching your filters
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
openmemory/ui/app/apps/page.tsx
Normal file
20
openmemory/ui/app/apps/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { AppFilters } from "./components/AppFilters";
|
||||
import { AppGrid } from "./components/AppGrid";
|
||||
import "@/styles/animation.css";
|
||||
|
||||
export default function AppsPage() {
|
||||
return (
|
||||
<main className="flex-1 py-6">
|
||||
<div className="container">
|
||||
<div className="mt-1 pb-4 animate-fade-slide-down">
|
||||
<AppFilters />
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-1">
|
||||
<AppGrid />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
59
openmemory/ui/app/globals.css
Normal file
59
openmemory/ui/app/globals.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 260 94% 59%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 260 94% 59%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 260 94% 59%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 260 94% 59%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
38
openmemory/ui/app/layout.tsx
Normal file
38
openmemory/ui/app/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type React from "react";
|
||||
import "@/app/globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata = {
|
||||
title: "OpenMemory - Developer Dashboard",
|
||||
description: "Manage your OpenMemory integration and stored memories",
|
||||
generator: "v0.dev",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="h-screen font-sans antialiased flex flex-col bg-zinc-950">
|
||||
<Providers>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Navbar />
|
||||
<ScrollArea className="h-[calc(100vh-64px)]">{children}</ScrollArea>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
3
openmemory/ui/app/loading.tsx
Normal file
3
openmemory/ui/app/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null;
|
||||
}
|
||||
88
openmemory/ui/app/memories/components/CreateMemoryDialog.tsx
Normal file
88
openmemory/ui/app/memories/components/CreateMemoryDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState, useRef } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { toast } from "sonner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function CreateMemoryDialog() {
|
||||
const { createMemory, isLoading, fetchMemories } = useMemoriesApi();
|
||||
const [open, setOpen] = useState(false);
|
||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleCreateMemory = async (text: string) => {
|
||||
try {
|
||||
await createMemory(text);
|
||||
toast.success("Memory created successfully");
|
||||
// close the dialog
|
||||
setOpen(false);
|
||||
// refetch memories
|
||||
await fetchMemories();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to create memory");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 text-white"
|
||||
>
|
||||
<GoPlus />
|
||||
Create Memory
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[525px] bg-zinc-900 border-zinc-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Memory</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new memory to your OpenMemory instance
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memory">Memory</Label>
|
||||
<Textarea
|
||||
ref={textRef}
|
||||
id="memory"
|
||||
placeholder="e.g., Lives in San Francisco"
|
||||
className="bg-zinc-950 border-zinc-800 min-h-[150px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onClick={() => handleCreateMemory(textRef?.current?.value || "")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
"Save Memory"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
411
openmemory/ui/app/memories/components/FilterComponent.tsx
Normal file
411
openmemory/ui/app/memories/components/FilterComponent.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Filter, X, ChevronDown, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { useFiltersApi } from "@/hooks/useFiltersApi";
|
||||
import {
|
||||
setSelectedApps,
|
||||
setSelectedCategories,
|
||||
clearFilters,
|
||||
} from "@/store/filtersSlice";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: "Memory",
|
||||
value: "memory",
|
||||
},
|
||||
{
|
||||
label: "App Name",
|
||||
value: "app_name",
|
||||
},
|
||||
{
|
||||
label: "Created On",
|
||||
value: "created_at",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FilterComponent() {
|
||||
const dispatch = useDispatch();
|
||||
const { fetchApps } = useAppsApi();
|
||||
const { fetchCategories, updateSort } = useFiltersApi();
|
||||
const { fetchMemories } = useMemoriesApi();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tempSelectedApps, setTempSelectedApps] = useState<string[]>([]);
|
||||
const [tempSelectedCategories, setTempSelectedCategories] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const apps = useSelector((state: RootState) => state.apps.apps);
|
||||
const categories = useSelector(
|
||||
(state: RootState) => state.filters.categories.items
|
||||
);
|
||||
const filters = useSelector((state: RootState) => state.filters.apps);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApps();
|
||||
fetchCategories();
|
||||
}, [fetchApps, fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize temporary selections with current active filters when dialog opens
|
||||
if (isOpen) {
|
||||
setTempSelectedApps(filters.selectedApps);
|
||||
setTempSelectedCategories(filters.selectedCategories);
|
||||
setShowArchived(filters.showArchived || false);
|
||||
}
|
||||
}, [isOpen, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
handleClearFilters();
|
||||
}, []);
|
||||
|
||||
const toggleAppFilter = (app: string) => {
|
||||
setTempSelectedApps((prev) =>
|
||||
prev.includes(app) ? prev.filter((a) => a !== app) : [...prev, app]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCategoryFilter = (category: string) => {
|
||||
setTempSelectedCategories((prev) =>
|
||||
prev.includes(category)
|
||||
? prev.filter((c) => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllApps = (checked: boolean) => {
|
||||
setTempSelectedApps(checked ? apps.map((app) => app.id) : []);
|
||||
};
|
||||
|
||||
const toggleAllCategories = (checked: boolean) => {
|
||||
setTempSelectedCategories(checked ? categories.map((cat) => cat.name) : []);
|
||||
};
|
||||
|
||||
const handleClearFilters = async () => {
|
||||
setTempSelectedApps([]);
|
||||
setTempSelectedCategories([]);
|
||||
setShowArchived(false);
|
||||
dispatch(clearFilters());
|
||||
await fetchMemories();
|
||||
};
|
||||
|
||||
const handleApplyFilters = async () => {
|
||||
try {
|
||||
// Get category IDs for selected category names
|
||||
const selectedCategoryIds = categories
|
||||
.filter((cat) => tempSelectedCategories.includes(cat.name))
|
||||
.map((cat) => cat.id);
|
||||
|
||||
// Get app IDs for selected app names
|
||||
const selectedAppIds = apps
|
||||
.filter((app) => tempSelectedApps.includes(app.id))
|
||||
.map((app) => app.id);
|
||||
|
||||
// Update the global state with temporary selections
|
||||
dispatch(setSelectedApps(tempSelectedApps));
|
||||
dispatch(setSelectedCategories(tempSelectedCategories));
|
||||
dispatch({ type: "filters/setShowArchived", payload: showArchived });
|
||||
|
||||
await fetchMemories(undefined, 1, 10, {
|
||||
apps: selectedAppIds,
|
||||
categories: selectedCategoryIds,
|
||||
sortColumn: filters.sortColumn,
|
||||
sortDirection: filters.sortDirection,
|
||||
showArchived: showArchived,
|
||||
});
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to apply filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
// Reset temporary selections to active filters when dialog closes without applying
|
||||
setTempSelectedApps(filters.selectedApps);
|
||||
setTempSelectedCategories(filters.selectedCategories);
|
||||
setShowArchived(filters.showArchived || false);
|
||||
}
|
||||
};
|
||||
|
||||
const setSorting = async (column: string) => {
|
||||
const newDirection =
|
||||
filters.sortColumn === column && filters.sortDirection === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
updateSort(column, newDirection);
|
||||
|
||||
// Get category IDs for selected category names
|
||||
const selectedCategoryIds = categories
|
||||
.filter((cat) => tempSelectedCategories.includes(cat.name))
|
||||
.map((cat) => cat.id);
|
||||
|
||||
// Get app IDs for selected app names
|
||||
const selectedAppIds = apps
|
||||
.filter((app) => tempSelectedApps.includes(app.id))
|
||||
.map((app) => app.id);
|
||||
|
||||
try {
|
||||
await fetchMemories(undefined, 1, 10, {
|
||||
apps: selectedAppIds,
|
||||
categories: selectedCategoryIds,
|
||||
sortColumn: column,
|
||||
sortDirection: newDirection,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to apply sorting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.selectedApps.length > 0 ||
|
||||
filters.selectedCategories.length > 0 ||
|
||||
filters.showArchived;
|
||||
|
||||
const hasTempFilters =
|
||||
tempSelectedApps.length > 0 ||
|
||||
tempSelectedCategories.length > 0 ||
|
||||
showArchived;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={handleDialogChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-9 px-4 border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800 ${
|
||||
hasActiveFilters ? "border-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<Filter
|
||||
className={`h-4 w-4 ${hasActiveFilters ? "text-primary" : ""}`}
|
||||
/>
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<Badge className="ml-2 bg-primary hover:bg-primary/80 text-xs">
|
||||
{filters.selectedApps.length +
|
||||
filters.selectedCategories.length +
|
||||
(filters.showArchived ? 1 : 0)}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-zinc-100 flex justify-between items-center">
|
||||
<span>Filters</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="apps" className="w-full">
|
||||
<TabsList className="grid grid-cols-3 bg-zinc-800">
|
||||
<TabsTrigger
|
||||
value="apps"
|
||||
className="data-[state=active]:bg-zinc-700"
|
||||
>
|
||||
Apps
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="categories"
|
||||
className="data-[state=active]:bg-zinc-700"
|
||||
>
|
||||
Categories
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
className="data-[state=active]:bg-zinc-700"
|
||||
>
|
||||
Archived
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="apps" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="select-all-apps"
|
||||
checked={
|
||||
apps.length > 0 && tempSelectedApps.length === apps.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllApps(checked as boolean)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="select-all-apps"
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
Select All
|
||||
</Label>
|
||||
</div>
|
||||
{apps.map((app) => (
|
||||
<div key={app.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`app-${app.id}`}
|
||||
checked={tempSelectedApps.includes(app.id)}
|
||||
onCheckedChange={() => toggleAppFilter(app.id)}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`app-${app.id}`}
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
{app.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="categories" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="select-all-categories"
|
||||
checked={
|
||||
categories.length > 0 &&
|
||||
tempSelectedCategories.length === categories.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllCategories(checked as boolean)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="select-all-categories"
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
Select All
|
||||
</Label>
|
||||
</div>
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`category-${category.name}`}
|
||||
checked={tempSelectedCategories.includes(category.name)}
|
||||
onCheckedChange={() =>
|
||||
toggleCategoryFilter(category.name)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.name}`}
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="archived" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="show-archived"
|
||||
checked={showArchived}
|
||||
onCheckedChange={(checked) =>
|
||||
setShowArchived(checked as boolean)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-archived"
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
Show Archived Memories
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="flex justify-end mt-4 gap-3">
|
||||
{/* Clear all button */}
|
||||
{hasTempFilters && (
|
||||
<Button
|
||||
onClick={handleClearFilters}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
{/* Apply filters button */}
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
className="bg-primary hover:bg-primary/80 text-white"
|
||||
>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 px-4 border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
{filters.sortDirection === "asc" ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
Sort: {columns.find((c) => c.value === filters.sortColumn)?.label}
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-zinc-800" />
|
||||
<DropdownMenuGroup>
|
||||
{columns.map((column) => (
|
||||
<DropdownMenuItem
|
||||
key={column.value}
|
||||
onClick={() => setSorting(column.value)}
|
||||
className="cursor-pointer flex justify-between items-center"
|
||||
>
|
||||
{column.label}
|
||||
{filters.sortColumn === column.value &&
|
||||
(filters.sortDirection === "asc" ? (
|
||||
<SortAsc className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4 text-primary" />
|
||||
))}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
openmemory/ui/app/memories/components/MemoriesSection.tsx
Normal file
145
openmemory/ui/app/memories/components/MemoriesSection.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Category, Client } from "../../../components/types";
|
||||
import { MemoryTable } from "./MemoryTable";
|
||||
import { MemoryPagination } from "./MemoryPagination";
|
||||
import { CreateMemoryDialog } from "./CreateMemoryDialog";
|
||||
import { PageSizeSelector } from "./PageSizeSelector";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { MemoryTableSkeleton } from "@/skeleton/MemoryTableSkeleton";
|
||||
|
||||
export function MemoriesSection() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { fetchMemories } = useMemoriesApi();
|
||||
const [memories, setMemories] = useState<any[]>([]);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const currentPage = Number(searchParams.get("page")) || 1;
|
||||
const itemsPerPage = Number(searchParams.get("size")) || 10;
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | "all">(
|
||||
"all"
|
||||
);
|
||||
const [selectedClient, setSelectedClient] = useState<Client | "all">("all");
|
||||
|
||||
useEffect(() => {
|
||||
const loadMemories = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const searchQuery = searchParams.get("search") || "";
|
||||
const result = await fetchMemories(
|
||||
searchQuery,
|
||||
currentPage,
|
||||
itemsPerPage
|
||||
);
|
||||
setMemories(result.memories);
|
||||
setTotalItems(result.total);
|
||||
setTotalPages(result.pages);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch memories:", error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadMemories();
|
||||
}, [currentPage, itemsPerPage, fetchMemories, searchParams]);
|
||||
|
||||
const setCurrentPage = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", page.toString());
|
||||
params.set("size", itemsPerPage.toString());
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", "1"); // Reset to page 1 when changing page size
|
||||
params.set("size", size.toString());
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full bg-transparent">
|
||||
<MemoryTableSkeleton />
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="h-8 w-32 bg-zinc-800 rounded animate-pulse" />
|
||||
<div className="h-8 w-48 bg-zinc-800 rounded animate-pulse" />
|
||||
<div className="h-8 w-32 bg-zinc-800 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-transparent">
|
||||
<div>
|
||||
{memories.length > 0 ? (
|
||||
<>
|
||||
<MemoryTable />
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<PageSizeSelector
|
||||
pageSize={itemsPerPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
<div className="text-sm text-zinc-500 mr-2">
|
||||
Showing {(currentPage - 1) * itemsPerPage + 1} to{" "}
|
||||
{Math.min(currentPage * itemsPerPage, totalItems)} of{" "}
|
||||
{totalItems} memories
|
||||
</div>
|
||||
<MemoryPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
setCurrentPage={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-full bg-zinc-800 p-3 mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-6 w-6 text-zinc-400"
|
||||
>
|
||||
<path d="M21 9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7"></path>
|
||||
<path d="M16 2v6h6"></path>
|
||||
<path d="M12 18v-6"></path>
|
||||
<path d="M9 15h6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">No memories found</h3>
|
||||
<p className="text-zinc-400 mt-1 mb-4">
|
||||
{selectedCategory !== "all" || selectedClient !== "all"
|
||||
? "Try adjusting your filters"
|
||||
: "Create your first memory to see it here"}
|
||||
</p>
|
||||
{selectedCategory !== "all" || selectedClient !== "all" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedCategory("all");
|
||||
setSelectedClient("all");
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<CreateMemoryDialog />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
openmemory/ui/app/memories/components/MemoryFilters.tsx
Normal file
154
openmemory/ui/app/memories/components/MemoryFilters.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
import { Archive, Pause, Play, Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FiTrash2 } from "react-icons/fi";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { clearSelection } from "@/store/memoriesSlice";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { debounce } from "lodash";
|
||||
import { useEffect, useRef } from "react";
|
||||
import FilterComponent from "./FilterComponent";
|
||||
import { clearFilters } from "@/store/filtersSlice";
|
||||
|
||||
export function MemoryFilters() {
|
||||
const dispatch = useDispatch();
|
||||
const selectedMemoryIds = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemoryIds
|
||||
);
|
||||
const { deleteMemories, updateMemoryState, fetchMemories } = useMemoriesApi();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeFilters = useSelector((state: RootState) => state.filters.apps);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
try {
|
||||
await deleteMemories(selectedMemoryIds);
|
||||
dispatch(clearSelection());
|
||||
} catch (error) {
|
||||
console.error("Failed to delete memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveSelected = async () => {
|
||||
try {
|
||||
await updateMemoryState(selectedMemoryIds, "archived");
|
||||
} catch (error) {
|
||||
console.error("Failed to archive memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseSelected = async () => {
|
||||
try {
|
||||
await updateMemoryState(selectedMemoryIds, "paused");
|
||||
} catch (error) {
|
||||
console.error("Failed to pause memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSelected = async () => {
|
||||
try {
|
||||
await updateMemoryState(selectedMemoryIds, "active");
|
||||
} catch (error) {
|
||||
console.error("Failed to resume memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// add debounce
|
||||
const handleSearch = debounce(async (query: string) => {
|
||||
router.push(`/memories?search=${query}`);
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
// if the url has a search param, set the input value to the search param
|
||||
if (searchParams.get("search")) {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = searchParams.get("search") || "";
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClearAllFilters = async () => {
|
||||
dispatch(clearFilters());
|
||||
await fetchMemories(); // Fetch memories without any filters
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
activeFilters.selectedApps.length > 0 ||
|
||||
activeFilters.selectedCategories.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Search memories..."
|
||||
className="pl-8 bg-zinc-950 border-zinc-800 max-w-[500px]"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FilterComponent />
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-zinc-900 text-zinc-300 hover:bg-zinc-800"
|
||||
onClick={handleClearAllFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
{selectedMemoryIds.length > 0 && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-900 border-zinc-800"
|
||||
>
|
||||
<DropdownMenuItem onClick={handleArchiveSelected}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePauseSelected}>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleResumeSelected}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteSelected}
|
||||
className="text-red-500"
|
||||
>
|
||||
<FiTrash2 className="mr-2 h-4 w-4" />
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
openmemory/ui/app/memories/components/MemoryPagination.tsx
Normal file
40
openmemory/ui/app/memories/components/MemoryPagination.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MemoryPaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
}
|
||||
|
||||
export function MemoryPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
setCurrentPage,
|
||||
}: MemoryPaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between my-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentPage(Math.min(currentPage + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
openmemory/ui/app/memories/components/MemoryTable.tsx
Normal file
302
openmemory/ui/app/memories/components/MemoryTable.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Pause,
|
||||
Archive,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import {
|
||||
selectMemory,
|
||||
deselectMemory,
|
||||
selectAllMemories,
|
||||
clearSelection,
|
||||
} from "@/store/memoriesSlice";
|
||||
import SourceApp from "@/components/shared/source-app";
|
||||
import { HiMiniRectangleStack } from "react-icons/hi2";
|
||||
import { PiSwatches } from "react-icons/pi";
|
||||
import { GoPackage } from "react-icons/go";
|
||||
import { CiCalendar } from "react-icons/ci";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate } from "@/lib/helpers";
|
||||
|
||||
export function MemoryTable() {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
const selectedMemoryIds = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemoryIds
|
||||
);
|
||||
const memories = useSelector((state: RootState) => state.memories.memories);
|
||||
|
||||
const { deleteMemories, updateMemoryState, isLoading } = useMemoriesApi();
|
||||
|
||||
const handleDeleteMemory = (id: string) => {
|
||||
deleteMemories([id]);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
dispatch(selectAllMemories());
|
||||
} else {
|
||||
dispatch(clearSelection());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMemory = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
dispatch(selectMemory(id));
|
||||
} else {
|
||||
dispatch(deselectMemory(id));
|
||||
}
|
||||
};
|
||||
const { handleOpenUpdateMemoryDialog } = useUI();
|
||||
|
||||
const handleEditMemory = (memory_id: string, memory_content: string) => {
|
||||
handleOpenUpdateMemoryDialog(memory_id, memory_content);
|
||||
};
|
||||
|
||||
const handleUpdateMemoryState = async (id: string, newState: string) => {
|
||||
try {
|
||||
await updateMemoryState([id], newState);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update memory state",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected =
|
||||
memories.length > 0 && selectedMemoryIds.length === memories.length;
|
||||
const isPartiallySelected =
|
||||
selectedMemoryIds.length > 0 && selectedMemoryIds.length < memories.length;
|
||||
|
||||
const handleMemoryClick = (id: string) => {
|
||||
router.push(`/memory/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-zinc-800 hover:bg-zinc-800">
|
||||
<TableHead className="w-[50px] pl-4">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:border-primary border-zinc-500/50"
|
||||
checked={isAllSelected}
|
||||
data-state={
|
||||
isPartiallySelected
|
||||
? "indeterminate"
|
||||
: isAllSelected
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="border-zinc-700">
|
||||
<div className="flex items-center min-w-[600px]">
|
||||
<HiMiniRectangleStack className="mr-1" />
|
||||
Memory
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="border-zinc-700">
|
||||
<div className="flex items-center">
|
||||
<PiSwatches className="mr-1" size={15} />
|
||||
Categories
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] border-zinc-700">
|
||||
<div className="flex items-center">
|
||||
<GoPackage className="mr-1" />
|
||||
Source App
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] border-zinc-700">
|
||||
<div className="flex items-center w-full justify-center">
|
||||
<CiCalendar className="mr-1" size={16} />
|
||||
Created On
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right border-zinc-700 flex justify-center">
|
||||
<div className="flex items-center justify-end">
|
||||
<MoreHorizontal className="h-4 w-4 mr-2" />
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{memories.map((memory) => (
|
||||
<TableRow
|
||||
key={memory.id}
|
||||
className={`hover:bg-zinc-900/50 ${
|
||||
memory.state === "paused" || memory.state === "archived"
|
||||
? "text-zinc-400"
|
||||
: ""
|
||||
} ${isLoading ? "animate-pulse opacity-50" : ""}`}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:border-primary border-zinc-500/50"
|
||||
checked={selectedMemoryIds.includes(memory.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectMemory(memory.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{memory.state === "paused" || memory.state === "archived" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => handleMemoryClick(memory.id)}
|
||||
className={`font-medium ${
|
||||
memory.state === "paused" ||
|
||||
memory.state === "archived"
|
||||
? "text-zinc-400"
|
||||
: "text-white"
|
||||
} cursor-pointer`}
|
||||
>
|
||||
{memory.memory}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
This memory is{" "}
|
||||
<span className="font-bold">
|
||||
{memory.state === "paused" ? "paused" : "archived"}
|
||||
</span>{" "}
|
||||
and <span className="font-bold">disabled</span>.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => handleMemoryClick(memory.id)}
|
||||
className={`font-medium text-white cursor-pointer`}
|
||||
>
|
||||
{memory.memory}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Categories
|
||||
categories={memory.categories}
|
||||
isPaused={
|
||||
memory.state === "paused" || memory.state === "archived"
|
||||
}
|
||||
concat={true}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[140px] text-center">
|
||||
<SourceApp source={memory.app_name} />
|
||||
</TableCell>
|
||||
<TableCell className="w-[140px] text-center">
|
||||
{formatDate(memory.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right flex justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-900 border-zinc-800"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
const newState =
|
||||
memory.state === "active" ? "paused" : "active";
|
||||
handleUpdateMemoryState(memory.id, newState);
|
||||
}}
|
||||
>
|
||||
{memory?.state === "active" ? (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
const newState =
|
||||
memory.state === "active" ? "archived" : "active";
|
||||
handleUpdateMemoryState(memory.id, newState);
|
||||
}}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{memory?.state !== "archived" ? (
|
||||
<>Archive</>
|
||||
) : (
|
||||
<>Unarchive</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleEditMemory(memory.id, memory.memory)}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-500 focus:text-red-500"
|
||||
onClick={() => handleDeleteMemory(memory.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
openmemory/ui/app/memories/components/PageSizeSelector.tsx
Normal file
43
openmemory/ui/app/memories/components/PageSizeSelector.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface PageSizeSelectorProps {
|
||||
pageSize: number;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
export function PageSizeSelector({
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
}: PageSizeSelectorProps) {
|
||||
const pageSizeOptions = [10, 20, 50, 100];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-500">Show</span>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[70px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-zinc-500">items</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageSizeSelector;
|
||||
45
openmemory/ui/app/memories/page.tsx
Normal file
45
openmemory/ui/app/memories/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { MemoriesSection } from "@/app/memories/components/MemoriesSection";
|
||||
import { MemoryFilters } from "@/app/memories/components/MemoryFilters";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import "@/styles/animation.css";
|
||||
import UpdateMemory from "@/components/shared/update-memory";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
|
||||
export default function MemoriesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateMemoryDialog, handleCloseUpdateMemoryDialog } = useUI();
|
||||
useEffect(() => {
|
||||
// Set default pagination values if not present in URL
|
||||
if (!searchParams.has("page") || !searchParams.has("size")) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (!searchParams.has("page")) params.set("page", "1");
|
||||
if (!searchParams.has("size")) params.set("size", "10");
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<UpdateMemory
|
||||
memoryId={updateMemoryDialog.memoryId || ""}
|
||||
memoryContent={updateMemoryDialog.memoryContent || ""}
|
||||
open={updateMemoryDialog.isOpen}
|
||||
onOpenChange={handleCloseUpdateMemoryDialog}
|
||||
/>
|
||||
<main className="flex-1 py-6">
|
||||
<div className="container">
|
||||
<div className="mt-1 pb-4 animate-fade-slide-down">
|
||||
<MemoryFilters />
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-1">
|
||||
<MemoriesSection />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
openmemory/ui/app/memory/[id]/components/AccessLog.tsx
Normal file
116
openmemory/ui/app/memory/[id]/components/AccessLog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface AccessLogEntry {
|
||||
id: string;
|
||||
app_name: string;
|
||||
accessed_at: string;
|
||||
}
|
||||
|
||||
interface AccessLogProps {
|
||||
memoryId: string;
|
||||
}
|
||||
|
||||
export function AccessLog({ memoryId }: AccessLogProps) {
|
||||
const { fetchAccessLogs } = useMemoriesApi();
|
||||
const accessEntries = useSelector(
|
||||
(state: RootState) => state.memories.accessLogs
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAccessLogs = async () => {
|
||||
try {
|
||||
await fetchAccessLogs(memoryId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch access logs:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAccessLogs();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto rounded-3xl overflow-hidden bg-[#1c1c1c] text-white p-6">
|
||||
<p className="text-center text-zinc-500">Loading access logs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 text-white pb-1">
|
||||
<div className="px-6 py-4 flex justify-between items-center bg-zinc-800 border-b border-zinc-800">
|
||||
<h2 className="font-semibold">Access Log</h2>
|
||||
{/* <button className="px-3 py-1 text-sm rounded-lg border border-[#ff5533] text-[#ff5533] flex items-center gap-2 hover:bg-[#ff5533]/10 transition-colors">
|
||||
<PauseIcon size={18} />
|
||||
<span>Pause Access</span>
|
||||
</button> */}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="p-6 max-h-[450px]">
|
||||
{accessEntries.length === 0 && (
|
||||
<div className="w-full max-w-md mx-auto rounded-3xl overflow-hidden min-h-[110px] flex items-center justify-center text-white p-6">
|
||||
<p className="text-center text-zinc-500">
|
||||
No access logs available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-8">
|
||||
{accessEntries.map((entry: AccessLogEntry, index: number) => {
|
||||
const appConfig =
|
||||
constants[entry.app_name as keyof typeof constants] ||
|
||||
constants.default;
|
||||
|
||||
return (
|
||||
<li key={entry.id} className="relative flex items-start gap-4">
|
||||
<div className="relative z-10 rounded-full overflow-hidden bg-[#2a2a2a] w-8 h-8 flex items-center justify-center flex-shrink-0">
|
||||
{appConfig.iconImage ? (
|
||||
<Image
|
||||
src={appConfig.iconImage}
|
||||
alt={`${appConfig.name} icon`}
|
||||
width={30}
|
||||
height={30}
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{appConfig.icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{index < accessEntries.length - 1 && (
|
||||
<div className="absolute left-4 top-6 bottom-0 w-[1px] h-[calc(100%+1rem)] bg-[#333333] transform -translate-x-1/2"></div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{appConfig.name}</span>
|
||||
<span className="text-zinc-400 text-sm">
|
||||
{new Date(entry.accessed_at + "Z").toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
openmemory/ui/app/memory/[id]/components/MemoryActions.tsx
Normal file
114
openmemory/ui/app/memory/[id]/components/MemoryActions.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pencil, Archive, Trash, Pause, Play, ChevronDown } from "lucide-react";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface MemoryActionsProps {
|
||||
memoryId: string;
|
||||
memoryContent: string;
|
||||
memoryState: string;
|
||||
}
|
||||
|
||||
export function MemoryActions({
|
||||
memoryId,
|
||||
memoryContent,
|
||||
memoryState,
|
||||
}: MemoryActionsProps) {
|
||||
const { handleOpenUpdateMemoryDialog } = useUI();
|
||||
const { updateMemoryState, isLoading } = useMemoriesApi();
|
||||
|
||||
const handleEdit = () => {
|
||||
handleOpenUpdateMemoryDialog(memoryId, memoryContent);
|
||||
};
|
||||
|
||||
const handleStateChange = (newState: string) => {
|
||||
updateMemoryState([memoryId], newState);
|
||||
};
|
||||
|
||||
const getStateLabel = () => {
|
||||
switch (memoryState) {
|
||||
case "archived":
|
||||
return "Archived";
|
||||
case "paused":
|
||||
return "Paused";
|
||||
default:
|
||||
return "Active";
|
||||
}
|
||||
};
|
||||
|
||||
const getStateIcon = () => {
|
||||
switch (memoryState) {
|
||||
case "archived":
|
||||
return <Archive className="h-3 w-3 mr-2" />;
|
||||
case "paused":
|
||||
return <Pause className="h-3 w-3 mr-2" />;
|
||||
default:
|
||||
return <Play className="h-3 w-3 mr-2" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shadow-md bg-zinc-900 border border-zinc-700/50 hover:bg-zinc-950 text-zinc-400"
|
||||
>
|
||||
<span className="font-semibold">{getStateLabel()}</span>
|
||||
<ChevronDown className="h-3 w-3 mt-1 -ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40 bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DropdownMenuLabel>Change State</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-zinc-800" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStateChange("active")}
|
||||
className="cursor-pointer flex items-center"
|
||||
disabled={memoryState === "active"}
|
||||
>
|
||||
<Play className="h-3 w-3 mr-2" />
|
||||
<span className="font-semibold">Active</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStateChange("paused")}
|
||||
className="cursor-pointer flex items-center"
|
||||
disabled={memoryState === "paused"}
|
||||
>
|
||||
<Pause className="h-3 w-3 mr-2" />
|
||||
<span className="font-semibold">Pause</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStateChange("archived")}
|
||||
className="cursor-pointer flex items-center"
|
||||
disabled={memoryState === "archived"}
|
||||
>
|
||||
<Archive className="h-3 w-3 mr-2" />
|
||||
<span className="font-semibold">Archive</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
className="shadow-md bg-zinc-900 border border-zinc-700/50 hover:bg-zinc-950 text-zinc-400"
|
||||
>
|
||||
<Pencil className="h-3 w-3 -mr-1" />
|
||||
<span className="font-semibold">Edit</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
openmemory/ui/app/memory/[id]/components/MemoryDetails.tsx
Normal file
151
openmemory/ui/app/memory/[id]/components/MemoryDetails.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { MemoryActions } from "./MemoryActions";
|
||||
import { ArrowLeft, Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AccessLog } from "./AccessLog";
|
||||
import Image from "next/image";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { RelatedMemories } from "./RelatedMemories";
|
||||
|
||||
interface MemoryDetailsProps {
|
||||
memory_id: string;
|
||||
}
|
||||
|
||||
export function MemoryDetails({ memory_id }: MemoryDetailsProps) {
|
||||
const router = useRouter();
|
||||
const { fetchMemoryById, hasUpdates } = useMemoriesApi();
|
||||
const memory = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemory
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (memory?.id) {
|
||||
await navigator.clipboard.writeText(memory.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemoryById(memory_id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 text-zinc-400 hover:text-white"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Memories
|
||||
</Button>
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="rounded-lg w-2/3 border h-fit pb-2 border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="">
|
||||
<div className="flex px-6 py-3 justify-between items-center mb-6 bg-zinc-800 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="font-semibold text-white">
|
||||
Memory{" "}
|
||||
<span className="ml-1 text-zinc-400 text-sm font-normal">
|
||||
#{memory?.id?.slice(0, 6)}
|
||||
</span>
|
||||
</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 text-zinc-400 hover:text-white -ml-[5px] mt-1"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<MemoryActions
|
||||
memoryId={memory?.id || ""}
|
||||
memoryContent={memory?.text || ""}
|
||||
memoryState={memory?.state || ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-2">
|
||||
<div className="border-l-2 border-primary pl-4 mb-6">
|
||||
<p
|
||||
className={`${
|
||||
memory?.state === "archived" || memory?.state === "paused"
|
||||
? "text-zinc-400"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{memory?.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-zinc-800">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="">
|
||||
<Categories
|
||||
categories={memory?.categories || []}
|
||||
isPaused={
|
||||
memory?.state === "archived" ||
|
||||
memory?.state === "paused"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-[300px] justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-zinc-700 px-3 py-1 rounded-lg">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Created by:
|
||||
</span>
|
||||
<div className="w-4 h-4 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
constants[
|
||||
memory?.app_name as keyof typeof constants
|
||||
]?.iconImage || ""
|
||||
}
|
||||
alt="OpenMemory"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-100 font-semibold">
|
||||
{
|
||||
constants[
|
||||
memory?.app_name as keyof typeof constants
|
||||
]?.name
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex justify-end gap-2 w-full mt-2">
|
||||
<p className="text-sm font-semibold text-primary my-auto">
|
||||
{new Date(memory.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 flex flex-col gap-4">
|
||||
<AccessLog memoryId={memory?.id || ""} />
|
||||
<RelatedMemories memoryId={memory?.id || ""} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
openmemory/ui/app/memory/[id]/components/RelatedMemories.tsx
Normal file
90
openmemory/ui/app/memory/[id]/components/RelatedMemories.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Memory } from "@/components/types";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/helpers";
|
||||
interface RelatedMemoriesProps {
|
||||
memoryId: string;
|
||||
}
|
||||
|
||||
export function RelatedMemories({ memoryId }: RelatedMemoriesProps) {
|
||||
const { fetchRelatedMemories } = useMemoriesApi();
|
||||
const relatedMemories = useSelector(
|
||||
(state: RootState) => state.memories.relatedMemories
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRelatedMemories = async () => {
|
||||
try {
|
||||
await fetchRelatedMemories(memoryId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch related memories:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRelatedMemories();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 text-white p-6">
|
||||
<p className="text-center text-zinc-500">Loading related memories...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!relatedMemories.length) {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 text-white p-6">
|
||||
<p className="text-center text-zinc-500">No related memories found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 text-white">
|
||||
<div className="px-6 py-4 flex justify-between items-center bg-zinc-800 border-b border-zinc-800">
|
||||
<h2 className="font-semibold">Related Memories</h2>
|
||||
</div>
|
||||
<div className="space-y-6 p-6">
|
||||
{relatedMemories.map((memory: Memory) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
className="border-l-2 border-zinc-800 pl-6 py-1 hover:bg-zinc-700/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<Link href={`/memory/${memory.id}`}>
|
||||
<h3 className="font-medium mb-3">{memory.memory}</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Categories
|
||||
categories={memory.categories}
|
||||
isPaused={
|
||||
memory.state === "paused" || memory.state === "archived"
|
||||
}
|
||||
concat={true}
|
||||
/>
|
||||
{memory.state !== "active" && (
|
||||
<span className="inline-block px-3 border border-yellow-600 text-yellow-600 font-semibold text-xs rounded-full bg-yellow-400/10 backdrop-blur-sm">
|
||||
{memory.state === "paused" ? "Paused" : "Archived"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-zinc-400 text-sm">
|
||||
{formatDate(memory.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
openmemory/ui/app/memory/[id]/page.tsx
Normal file
69
openmemory/ui/app/memory/[id]/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import "@/styles/animation.css";
|
||||
import { useEffect } from "react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { use } from "react";
|
||||
import { MemorySkeleton } from "@/skeleton/MemorySkeleton";
|
||||
import { MemoryDetails } from "./components/MemoryDetails";
|
||||
import UpdateMemory from "@/components/shared/update-memory";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import NotFound from "@/app/not-found";
|
||||
|
||||
function MemoryContent({ id }: { id: string }) {
|
||||
const { fetchMemoryById, isLoading, error } = useMemoriesApi();
|
||||
const memory = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemory
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMemory = async () => {
|
||||
try {
|
||||
await fetchMemoryById(id);
|
||||
} catch (err) {
|
||||
console.error("Failed to load memory:", err);
|
||||
}
|
||||
};
|
||||
loadMemory();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <MemorySkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <NotFound message={error} />;
|
||||
}
|
||||
|
||||
if (!memory) {
|
||||
return <NotFound message="Memory not found" statusCode={404} />;
|
||||
}
|
||||
|
||||
return <MemoryDetails memory_id={memory.id} />;
|
||||
}
|
||||
|
||||
export default function MemoryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const resolvedParams = use(params);
|
||||
const { updateMemoryDialog, handleCloseUpdateMemoryDialog } = useUI();
|
||||
return (
|
||||
<div>
|
||||
<div className="animate-fade-slide-down delay-1">
|
||||
<UpdateMemory
|
||||
memoryId={updateMemoryDialog.memoryId || ""}
|
||||
memoryContent={updateMemoryDialog.memoryContent || ""}
|
||||
open={updateMemoryDialog.isOpen}
|
||||
onOpenChange={handleCloseUpdateMemoryDialog}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-2">
|
||||
<MemoryContent id={resolvedParams.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
openmemory/ui/app/not-found.tsx
Normal file
53
openmemory/ui/app/not-found.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import "@/styles/notfound.scss";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NotFoundProps {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const getStatusCode = (message: string) => {
|
||||
const possibleStatusCodes = ["404", "403", "500", "422"];
|
||||
const potentialStatusCode = possibleStatusCodes.find((code) =>
|
||||
message.includes(code)
|
||||
);
|
||||
return potentialStatusCode ? parseInt(potentialStatusCode) : undefined;
|
||||
};
|
||||
|
||||
export default function NotFound({
|
||||
statusCode,
|
||||
message = "Page Not Found",
|
||||
title,
|
||||
}: NotFoundProps) {
|
||||
const potentialStatusCode = getStatusCode(message);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-100px)]">
|
||||
<div className="site">
|
||||
<div className="sketch">
|
||||
<div className="bee-sketch red"></div>
|
||||
<div className="bee-sketch blue"></div>
|
||||
</div>
|
||||
<h1>
|
||||
{statusCode
|
||||
? `${statusCode}:`
|
||||
: potentialStatusCode
|
||||
? `${potentialStatusCode}:`
|
||||
: "404"}
|
||||
<small>{title || message || "Page Not Found"}</small>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-primary text-white hover:bg-primary/80"
|
||||
>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
openmemory/ui/app/page.tsx
Normal file
38
openmemory/ui/app/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Install } from "@/components/dashboard/Install";
|
||||
import Stats from "@/components/dashboard/Stats";
|
||||
import { MemoryFilters } from "@/app/memories/components/MemoryFilters";
|
||||
import { MemoriesSection } from "@/app/memories/components/MemoriesSection";
|
||||
import "@/styles/animation.css";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="text-white py-6">
|
||||
<div className="container">
|
||||
<div className="w-full mx-auto space-y-6">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Memory Category Breakdown */}
|
||||
<div className="col-span-2 animate-fade-slide-down">
|
||||
<Install />
|
||||
</div>
|
||||
|
||||
{/* Memories Stats */}
|
||||
<div className="col-span-1 animate-fade-slide-down delay-1">
|
||||
<Stats />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="animate-fade-slide-down delay-2">
|
||||
<MemoryFilters />
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-3">
|
||||
<MemoriesSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
openmemory/ui/app/providers.tsx
Normal file
8
openmemory/ui/app/providers.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "../store/store";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
Reference in New Issue
Block a user