aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/web/src/app/dashboard/components/index.ts5
-rw-r--r--packages/web/src/app/dashboard/components/memory-card.tsx66
-rw-r--r--packages/web/src/app/dashboard/components/memory-edit-modal.tsx109
-rw-r--r--packages/web/src/app/dashboard/components/project-badge.tsx21
-rw-r--r--packages/web/src/app/dashboard/components/project-filter.tsx35
-rw-r--r--packages/web/src/app/dashboard/components/search-bar.tsx24
-rw-r--r--packages/web/src/app/dashboard/dashboard-content.tsx334
-rw-r--r--packages/web/src/env.js2
-rw-r--r--packages/web/src/hooks/use-debounce.ts19
-rw-r--r--packages/web/src/server/api/root.ts2
-rw-r--r--packages/web/src/server/api/routers/memory.ts142
-rw-r--r--packages/web/src/server/api/routers/project.ts79
12 files changed, 763 insertions, 75 deletions
diff --git a/packages/web/src/app/dashboard/components/index.ts b/packages/web/src/app/dashboard/components/index.ts
new file mode 100644
index 0000000..e0ec52d
--- /dev/null
+++ b/packages/web/src/app/dashboard/components/index.ts
@@ -0,0 +1,5 @@
+export { MemoryCard } from "./memory-card";
+export { MemoryEditModal } from "./memory-edit-modal";
+export { ProjectBadge } from "./project-badge";
+export { ProjectFilter } from "./project-filter";
+export { SearchBar } from "./search-bar";
diff --git a/packages/web/src/app/dashboard/components/memory-card.tsx b/packages/web/src/app/dashboard/components/memory-card.tsx
new file mode 100644
index 0000000..2b4aca1
--- /dev/null
+++ b/packages/web/src/app/dashboard/components/memory-card.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import type { Memory, Project } from "@imemio/sdk";
+import { ProjectBadge } from "./project-badge";
+
+type MemoryWithProject = Memory & {
+ project: Project | null;
+};
+
+type MemoryCardProps = {
+ memory: MemoryWithProject;
+ onEdit: () => void;
+ onDelete: () => void;
+ isDeleting?: boolean;
+};
+
+export function MemoryCard({
+ memory,
+ onEdit,
+ onDelete,
+ isDeleting,
+}: MemoryCardProps) {
+ return (
+ <div className="flex flex-col gap-2 border border-[#2a2a2a] bg-[#0f0f0f] p-3">
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1">
+ <p className="whitespace-pre-wrap text-white">{memory.content}</p>
+ </div>
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {memory.project && (
+ <ProjectBadge
+ isGlobal={memory.project.isGlobal}
+ name={memory.project.name}
+ />
+ )}
+
+ <span className="text-xs text-[#666666]">
+ {new Date(memory.createdAt).toLocaleString()}
+ </span>
+ </div>
+
+ <div className="flex gap-2">
+ <button
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-xs text-[#999999] transition hover:border-[#666666] hover:text-white"
+ onClick={onEdit}
+ type="button"
+ >
+ edit
+ </button>
+
+ <button
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-xs text-[#999999] transition hover:border-[#662222] hover:text-[#ff6666]"
+ disabled={isDeleting}
+ onClick={onDelete}
+ type="button"
+ >
+ {isDeleting ? "deleting ..." : "delete"}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/src/app/dashboard/components/memory-edit-modal.tsx b/packages/web/src/app/dashboard/components/memory-edit-modal.tsx
new file mode 100644
index 0000000..30f6161
--- /dev/null
+++ b/packages/web/src/app/dashboard/components/memory-edit-modal.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import type { Memory, Project } from "@imemio/sdk";
+import { useEffect, useState } from "react";
+
+type MemoryWithProject = Memory & {
+ project: Project | null;
+};
+
+type MemoryEditModalProps = {
+ memory: MemoryWithProject;
+ projects: Project[];
+ onSave: (data: { content: string; projectId: string }) => void;
+ onClose: () => void;
+ isSaving?: boolean;
+};
+
+export function MemoryEditModal({
+ memory,
+ projects,
+ onSave,
+ onClose,
+ isSaving,
+}: MemoryEditModalProps) {
+ const [content, setContent] = useState(memory.content);
+ const [projectId, setProjectId] = useState(memory.projectId);
+
+ useEffect(() => {
+ setContent(memory.content);
+ setProjectId(memory.projectId);
+ }, [memory]);
+
+ const handleSubmit = (formSubmitEvent: React.FormEvent) => {
+ formSubmitEvent.preventDefault();
+
+ if (content.trim()) {
+ onSave({ content, projectId });
+ }
+ };
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
+ <div className="w-full max-w-lg border border-[#2a2a2a] bg-[#0f0f0f] p-6">
+ <div className="mb-4 flex items-center justify-between">
+ <h2 className="text-lg text-white">
+ <span className="text-[#999999]">&gt;</span> edit memory
+ </h2>
+
+ <button
+ className="text-[#666666] transition hover:text-white"
+ onClick={onClose}
+ type="button"
+ >
+ [esc]
+ </button>
+ </div>
+
+ <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
+ <div>
+ <label className="mb-1 block text-xs text-[#666666]">content</label>
+ <textarea
+ className="w-full border border-[#2a2a2a] bg-[#070707] px-3 py-2 text-white placeholder:text-[#666666] focus:border-[#666666] focus:outline-none"
+ onChange={(textareaChangeEvent) =>
+ setContent(textareaChangeEvent.target.value)
+ }
+ rows={5}
+ value={content}
+ />
+ </div>
+
+ <div>
+ <label className="mb-1 block text-xs text-[#666666]">project</label>
+ <select
+ className="w-full border border-[#2a2a2a] bg-[#070707] px-3 py-2 text-white focus:border-[#666666] focus:outline-none"
+ onChange={(selectChangeEvent) =>
+ setProjectId(selectChangeEvent.target.value)
+ }
+ value={projectId}
+ >
+ {projects.map((project) => (
+ <option key={project.id} value={project.id}>
+ {project.isGlobal ? `* ${project.name} (global)` : project.name}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <button
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-[#999999] transition hover:border-[#666666] hover:text-white"
+ onClick={onClose}
+ type="button"
+ >
+ cancel
+ </button>
+
+ <button
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666] disabled:hover:border-[#2a2a2a]"
+ disabled={isSaving || !content.trim()}
+ type="submit"
+ >
+ {isSaving ? "saving ..." : "save changes"}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/src/app/dashboard/components/project-badge.tsx b/packages/web/src/app/dashboard/components/project-badge.tsx
new file mode 100644
index 0000000..ee95cbf
--- /dev/null
+++ b/packages/web/src/app/dashboard/components/project-badge.tsx
@@ -0,0 +1,21 @@
+type ProjectBadgeProps = {
+ name: string;
+ isGlobal?: boolean;
+};
+
+export function ProjectBadge({ name, isGlobal }: ProjectBadgeProps) {
+ if (isGlobal) {
+ return (
+ <span className="inline-flex items-center border border-[#3d3d1f] bg-[#1a1a0f] px-2 py-0.5 text-xs text-[#b3b366]">
+ <span className="mr-1 text-[#666633]">*</span>
+ {name}
+ </span>
+ );
+ }
+
+ return (
+ <span className="inline-flex items-center border border-[#2a2a2a] bg-[#0f0f0f] px-2 py-0.5 text-xs text-[#666666]">
+ {name}
+ </span>
+ );
+}
diff --git a/packages/web/src/app/dashboard/components/project-filter.tsx b/packages/web/src/app/dashboard/components/project-filter.tsx
new file mode 100644
index 0000000..d2b9818
--- /dev/null
+++ b/packages/web/src/app/dashboard/components/project-filter.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import type { Project } from "@imemio/sdk";
+
+type ProjectFilterProps = {
+ projects: Project[];
+ selectedProjectId: string | undefined;
+ onProjectChange: (projectId: string | undefined) => void;
+};
+
+export function ProjectFilter({
+ projects,
+ selectedProjectId,
+ onProjectChange,
+}: ProjectFilterProps) {
+ return (
+ <select
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1.5 text-sm text-white focus:border-[#666666] focus:outline-none"
+ onChange={(selectChangeEvent) => {
+ const value = selectChangeEvent.target.value;
+
+ onProjectChange(value === "" ? undefined : value);
+ }}
+ value={selectedProjectId ?? ""}
+ >
+ <option value="">all projects</option>
+
+ {projects.map((project) => (
+ <option key={project.id} value={project.id}>
+ {project.isGlobal ? `* ${project.name}` : project.name}
+ </option>
+ ))}
+ </select>
+ );
+}
diff --git a/packages/web/src/app/dashboard/components/search-bar.tsx b/packages/web/src/app/dashboard/components/search-bar.tsx
new file mode 100644
index 0000000..5791e22
--- /dev/null
+++ b/packages/web/src/app/dashboard/components/search-bar.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+type SearchBarProps = {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+};
+
+export function SearchBar({ value, onChange, placeholder }: SearchBarProps) {
+ return (
+ <div className="relative">
+ <span className="absolute left-3 top-1/2 -translate-y-1/2 text-[#666666]">
+ /
+ </span>
+ <input
+ className="w-full border border-[#2a2a2a] bg-[#0f0f0f] py-1.5 pl-7 pr-3 text-white placeholder:text-[#666666] focus:border-[#666666] focus:outline-none"
+ onChange={(inputChangeEvent) => onChange(inputChangeEvent.target.value)}
+ placeholder={placeholder ?? "search memories ..."}
+ type="text"
+ value={value}
+ />
+ </div>
+ );
+}
diff --git a/packages/web/src/app/dashboard/dashboard-content.tsx b/packages/web/src/app/dashboard/dashboard-content.tsx
index aab2cfd..cd52ea7 100644
--- a/packages/web/src/app/dashboard/dashboard-content.tsx
+++ b/packages/web/src/app/dashboard/dashboard-content.tsx
@@ -1,24 +1,38 @@
"use client";
+import type { Memory, Project } from "@imemio/sdk";
import Link from "next/link";
-import { useState } from "react";
+import { Suspense, useState } from "react";
+import { useDebounce } from "~/hooks/use-debounce";
import { api } from "~/trpc/react";
+import {
+ MemoryCard,
+ MemoryEditModal,
+ ProjectFilter,
+ SearchBar,
+} from "./components";
-function MemoryList() {
- const [memories] = api.memory.list.useSuspenseQuery();
- const trpcUtilities = api.useUtils();
- const deleteMemory = api.memory.delete.useMutation({
- onSuccess: async () => {
- await trpcUtilities.memory.invalidate();
- },
- });
+type MemoryWithProject = Memory & {
+ project: Project | null;
+};
+function MemoryListContent({
+ memories,
+ projects,
+ onEdit,
+ deletingId,
+ onDelete,
+}: {
+ memories: MemoryWithProject[];
+ projects: Project[];
+ onEdit: (memory: MemoryWithProject) => void;
+ deletingId: string | null;
+ onDelete: (id: string) => void;
+}) {
if (memories.length === 0) {
return (
<div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center">
- <p className="text-[#666666]">
- no memories yet. create your first one below.
- </p>
+ <p className="text-[#666666]">no memories found.</p>
</div>
);
}
@@ -26,32 +40,123 @@ function MemoryList() {
return (
<div className="flex w-full flex-col gap-2">
{memories.map((memory) => (
- <div
- className="flex items-start justify-between gap-4 border border-[#2a2a2a] bg-[#0f0f0f] p-3"
+ <MemoryCard
+ isDeleting={deletingId === memory.id}
key={memory.id}
- >
- <div className="flex-1">
- <p className="text-white">{memory.content}</p>
- <p className="mt-1 text-xs text-[#666666]">
- {new Date(memory.createdAt).toLocaleString()}
- </p>
- </div>
- <button
- className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-sm text-[#999999] transition hover:border-[#666666] hover:text-white"
- disabled={deleteMemory.isPending}
- onClick={() => deleteMemory.mutate({ id: memory.id })}
- type="button"
- >
- {deleteMemory.isPending ? "deleting ..." : "delete"}
- </button>
- </div>
+ memory={memory}
+ onDelete={() => onDelete(memory.id)}
+ onEdit={() => onEdit(memory)}
+ />
))}
</div>
);
}
-function CreateMemoryForm() {
+function MemoryList({
+ searchQuery,
+ selectedProjectId,
+ onEdit,
+}: {
+ searchQuery: string;
+ selectedProjectId: string | undefined;
+ onEdit: (memory: MemoryWithProject) => void;
+}) {
+ const debouncedSearchQuery = useDebounce(searchQuery, 300);
+ const [deletingId, setDeletingId] = useState<string | null>(null);
+ const trpcUtilities = api.useUtils();
+ const isSearching = debouncedSearchQuery.length > 0;
+ const listQuery = api.memory.listWithProjects.useQuery(
+ { projectId: selectedProjectId, sortOrder: "desc" },
+ { enabled: !isSearching },
+ );
+ const searchQueryResult = api.memory.search.useQuery(
+ { query: debouncedSearchQuery, projectId: selectedProjectId, limit: 50 },
+ { enabled: isSearching },
+ );
+ const deleteMemory = api.memory.delete.useMutation({
+ onMutate: ({ id }) => {
+ setDeletingId(id);
+ },
+ onSettled: () => {
+ setDeletingId(null);
+ },
+ onSuccess: async () => {
+ await trpcUtilities.memory.invalidate();
+ },
+ });
+ const handleDelete = (id: string) => {
+ deleteMemory.mutate({ id });
+ };
+
+ if (isSearching) {
+ if (searchQueryResult.isLoading) {
+ return (
+ <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center">
+ <p className="text-[#666666]">searching ...</p>
+ </div>
+ );
+ }
+
+ if (searchQueryResult.error) {
+ return (
+ <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center">
+ <p className="text-[#996666]">{searchQueryResult.error.message}</p>
+ </div>
+ );
+ }
+
+ const searchResults = searchQueryResult.data?.results ?? [];
+ const projects = searchQueryResult.data?.projects ?? [];
+ const memoriesWithProjects: MemoryWithProject[] = searchResults.map(
+ (result) => ({
+ id: result.id,
+ content: result.content,
+ projectId: result.projectId,
+ folderId: result.folderId,
+ tags: result.tags,
+ metadata: result.metadata,
+ createdAt: result.createdAt,
+ updatedAt: result.updatedAt,
+ project: projects.find((project) => project.id === result.projectId) ?? null,
+ }),
+ );
+
+ return (
+ <MemoryListContent
+ deletingId={deletingId}
+ memories={memoriesWithProjects}
+ onDelete={handleDelete}
+ onEdit={onEdit}
+ projects={projects}
+ />
+ );
+ }
+
+ if (listQuery.isLoading) {
+ return (
+ <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center">
+ <p className="text-[#666666]">loading ...</p>
+ </div>
+ );
+ }
+
+ const memories = listQuery.data?.memories ?? [];
+ const projects = listQuery.data?.projects ?? [];
+
+ return (
+ <MemoryListContent
+ deletingId={deletingId}
+ memories={memories}
+ onDelete={handleDelete}
+ onEdit={onEdit}
+ projects={projects}
+ />
+ );
+}
+
+function CreateMemoryForm({ projects }: { projects: Project[] }) {
const [content, setContent] = useState("");
+ const [projectId, setProjectId] = useState<string>("");
const trpcUtilities = api.useUtils();
const createMemory = api.memory.create.useMutation({
onSuccess: async () => {
@@ -59,6 +164,8 @@ function CreateMemoryForm() {
setContent("");
},
});
+ const globalProject = projects.find((project) => project.isGlobal);
+ const effectiveProjectId = projectId || globalProject?.id;
return (
<form
@@ -66,8 +173,8 @@ function CreateMemoryForm() {
onSubmit={(formSubmitEvent) => {
formSubmitEvent.preventDefault();
- if (content.trim()) {
- createMemory.mutate({ content });
+ if (content.trim() && effectiveProjectId) {
+ createMemory.mutate({ content, projectId: effectiveProjectId });
}
}}
>
@@ -80,50 +187,141 @@ function CreateMemoryForm() {
rows={3}
value={content}
/>
- <button
- className="border border-[#2a2a2a] bg-[#0f0f0f] px-6 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666] disabled:hover:border-[#2a2a2a]"
- disabled={createMemory.isPending || !content.trim()}
- type="submit"
- >
- {createMemory.isPending ? "creating ..." : "create memory"}
- </button>
+
+ <div className="flex items-center gap-3">
+ <select
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-2 text-sm text-white focus:border-[#666666] focus:outline-none"
+ onChange={(selectChangeEvent) =>
+ setProjectId(selectChangeEvent.target.value)
+ }
+ value={projectId}
+ >
+ <option value="">
+ {globalProject ? `* ${globalProject.name} (default)` : "select project"}
+ </option>
+
+ {projects
+ .filter((project) => !project.isGlobal)
+ .map((project) => (
+ <option key={project.id} value={project.id}>
+ {project.name}
+ </option>
+ ))}
+ </select>
+
+ <button
+ className="flex-1 border border-[#2a2a2a] bg-[#0f0f0f] px-6 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666] disabled:hover:border-[#2a2a2a]"
+ disabled={createMemory.isPending || !content.trim() || !effectiveProjectId}
+ type="submit"
+ >
+ {createMemory.isPending ? "creating ..." : "create memory"}
+ </button>
+ </div>
</form>
);
}
-export function DashboardContent() {
+function DashboardInner() {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(undefined);
+ const [editingMemory, setEditingMemory] = useState<MemoryWithProject | null>(null);
+ const trpcUtilities = api.useUtils();
+ const projectsQuery = api.project.list.useQuery();
+ const projects = projectsQuery.data ?? [];
+
+ api.project.getOrCreateGlobal.useQuery();
+
+ const updateMemory = api.memory.update.useMutation({
+ onSuccess: async () => {
+ await trpcUtilities.memory.invalidate();
+ setEditingMemory(null);
+ },
+ });
+ const handleSaveEdit = (data: { content: string; projectId: string }) => {
+ if (editingMemory) {
+ updateMemory.mutate({
+ id: editingMemory.id,
+ content: data.content,
+ projectId: data.projectId,
+ });
+ }
+ };
+
return (
- <main className="flex min-h-screen flex-col items-center bg-[#070707]">
- <div className="container flex max-w-2xl flex-col items-center gap-6 px-4 py-12">
- <div className="flex w-full items-center justify-between">
- <h1 className="text-2xl tracking-tight text-white">
- <span className="text-[#999999]">&gt;</span> memory dashboard
- </h1>
- <div className="flex gap-2">
- <Link
- className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white"
- href="/dashboard/settings"
- >
- settings
- </Link>
- <Link
- className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white"
- href="/"
- >
- home
- </Link>
+ <>
+ <div className="flex w-full items-center justify-between">
+ <h1 className="text-2xl tracking-tight text-white">
+ <span className="text-[#999999]">&gt;</span> memory dashboard
+ </h1>
+
+ <div className="flex gap-2">
+ <Link
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white"
+ href="/dashboard/settings"
+ >
+ settings
+ </Link>
+
+ <Link
+ className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white"
+ href="/"
+ >
+ home
+ </Link>
+ </div>
+ </div>
+
+ <section className="w-full">
+ <div className="mb-3 flex items-center gap-3">
+ <div className="flex-1">
+ <SearchBar onChange={setSearchQuery} value={searchQuery} />
</div>
+
+ <ProjectFilter
+ onProjectChange={setSelectedProjectId}
+ projects={projects}
+ selectedProjectId={selectedProjectId}
+ />
</div>
- <section className="w-full">
- <h2 className="mb-3 text-sm text-[#666666]">your memories</h2>
- <MemoryList />
- </section>
+ <MemoryList
+ onEdit={setEditingMemory}
+ searchQuery={searchQuery}
+ selectedProjectId={selectedProjectId}
+ />
+ </section>
- <section className="w-full">
- <h2 className="mb-3 text-sm text-[#666666]">create new memory</h2>
- <CreateMemoryForm />
- </section>
+ <section className="w-full">
+ <h2 className="mb-3 text-sm text-[#666666]">create new memory</h2>
+ <CreateMemoryForm projects={projects} />
+ </section>
+
+ {editingMemory && (
+ <MemoryEditModal
+ isSaving={updateMemory.isPending}
+ memory={editingMemory}
+ onClose={() => setEditingMemory(null)}
+ onSave={handleSaveEdit}
+ projects={projects}
+ />
+ )}
+ </>
+ );
+}
+
+export function DashboardContent() {
+ return (
+ <main className="flex min-h-screen flex-col items-center bg-[#070707]">
+ <div className="container flex max-w-2xl flex-col items-center gap-6 px-4 py-12">
+ <Suspense
+ fallback={
+ <div className="w-full text-center">
+ <p className="text-[#666666]">loading dashboard ...</p>
+ </div>
+ }
+ >
+ <DashboardInner />
+ </Suspense>
</div>
</main>
);
diff --git a/packages/web/src/env.js b/packages/web/src/env.js
index 5779b5f..8ee6297 100644
--- a/packages/web/src/env.js
+++ b/packages/web/src/env.js
@@ -11,6 +11,7 @@ export const env = createEnv({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
+ OPENAI_API_KEY: z.string().optional(),
},
/**
@@ -30,6 +31,7 @@ export const env = createEnv({
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
},
diff --git a/packages/web/src/hooks/use-debounce.ts b/packages/web/src/hooks/use-debounce.ts
new file mode 100644
index 0000000..cf5221f
--- /dev/null
+++ b/packages/web/src/hooks/use-debounce.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function useDebounce<T>(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
+
+ useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/packages/web/src/server/api/root.ts b/packages/web/src/server/api/root.ts
index 919ba21..c382d36 100644
--- a/packages/web/src/server/api/root.ts
+++ b/packages/web/src/server/api/root.ts
@@ -1,12 +1,14 @@
import { apiKeyRouter } from "~/server/api/routers/api-key";
import { memoryRouter } from "~/server/api/routers/memory";
import { postRouter } from "~/server/api/routers/post";
+import { projectRouter } from "~/server/api/routers/project";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
apiKey: apiKeyRouter,
memory: memoryRouter,
post: postRouter,
+ project: projectRouter,
});
// export type definition of API
diff --git a/packages/web/src/server/api/routers/memory.ts b/packages/web/src/server/api/routers/memory.ts
index e6d206b..eebe0f8 100644
--- a/packages/web/src/server/api/routers/memory.ts
+++ b/packages/web/src/server/api/routers/memory.ts
@@ -1,9 +1,21 @@
-import { SupabaseProjectStore, SupabaseStore } from "@imemio/sdk";
+import { EmbeddingService, SupabaseProjectStore, SupabaseStore } from "@imemio/sdk";
import type { SupabaseClient } from "@supabase/supabase-js";
+import { TRPCError } from "@trpc/server";
import { z } from "zod";
+import { env } from "~/env";
import { createClient } from "~/lib/supabase/server";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+function getEmbeddingService(): EmbeddingService | null {
+ const openaiApiKey = env.OPENAI_API_KEY;
+
+ if (!openaiApiKey) {
+ return null;
+ }
+
+ return new EmbeddingService({ apiKey: openaiApiKey });
+}
+
const defaultProjectName = "default";
async function getOrCreateDefaultProject(
@@ -47,14 +59,53 @@ export const memoryRouter = createTRPCRouter({
return memories;
}),
- create: protectedProcedure
- .input(z.object({ content: z.string().min(1) }))
- .mutation(async ({ context, input }) => {
+ listWithProjects: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string().optional(),
+ sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
+ }),
+ )
+ .query(async ({ ctx: context, input }) => {
const supabaseClient = await createClient();
- const projectId = await getOrCreateDefaultProject(
+ const memoryStore = new SupabaseStore(supabaseClient, context.user.id);
+ const projectStore = new SupabaseProjectStore(
supabaseClient,
context.user.id,
);
+ const memories = await memoryStore.list(
+ input.projectId ? { projectId: input.projectId } : undefined,
+ );
+ const projectsResult = await projectStore.list();
+ const projects = projectsResult.success ? projectsResult.value : [];
+ const projectMap = new Map(projects.map((project) => [project.id, project]));
+ const memoriesWithProjects = memories.map((memory) => ({
+ ...memory,
+ project: projectMap.get(memory.projectId) ?? null,
+ }));
+ const sortedMemories = memoriesWithProjects.sort((memoryA, memoryB) => {
+ const comparison =
+ new Date(memoryA.createdAt).getTime() -
+ new Date(memoryB.createdAt).getTime();
+
+ return input.sortOrder === "desc" ? -comparison : comparison;
+ });
+
+ return { memories: sortedMemories, projects };
+ }),
+
+ create: protectedProcedure
+ .input(
+ z.object({
+ content: z.string().min(1),
+ projectId: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx: context, input }) => {
+ const supabaseClient = await createClient();
+ const projectId =
+ input.projectId ??
+ (await getOrCreateDefaultProject(supabaseClient, context.user.id));
const memoryStore = new SupabaseStore(supabaseClient, context.user.id);
const memory = await memoryStore.create({
content: input.content,
@@ -64,17 +115,94 @@ export const memoryRouter = createTRPCRouter({
return memory;
}),
+ update: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ content: z.string().min(1).optional(),
+ projectId: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx: context, input }) => {
+ const supabaseClient = await createClient();
+ const memoryStore = new SupabaseStore(supabaseClient, context.user.id);
+ const updateData: { content?: string; projectId?: string } = {};
+
+ if (input.content !== undefined) {
+ updateData.content = input.content;
+ }
+
+ if (input.projectId !== undefined) {
+ updateData.projectId = input.projectId;
+ }
+
+ const updateResult = await memoryStore.update(input.id, updateData);
+
+ if (!updateResult.success) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Memory not found: ${input.id}`,
+ });
+ }
+
+ return updateResult.value;
+ }),
+
delete: protectedProcedure
.input(z.object({ id: z.string() }))
- .mutation(async ({ context, input }) => {
+ .mutation(async ({ ctx: context, input }) => {
const supabaseClient = await createClient();
const memoryStore = new SupabaseStore(supabaseClient, context.user.id);
const deleteResult = await memoryStore.delete(input.id);
if (!deleteResult.success) {
- throw new Error(`Memory not found: ${input.id}`);
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Memory not found: ${input.id}`,
+ });
}
return { success: true };
}),
+
+ search: protectedProcedure
+ .input(
+ z.object({
+ query: z.string().min(1),
+ projectId: z.string().optional(),
+ limit: z.number().optional().default(20),
+ }),
+ )
+ .query(async ({ ctx: context, input }) => {
+ const embeddingService = getEmbeddingService();
+
+ if (!embeddingService) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Search is not available. Configure OPENAI_API_KEY to enable semantic search.",
+ });
+ }
+
+ const supabaseClient = await createClient();
+ const memoryStore = new SupabaseStore(supabaseClient, context.user.id);
+ const projectStore = new SupabaseProjectStore(
+ supabaseClient,
+ context.user.id,
+ );
+ const queryEmbedding = await embeddingService.generate(input.query);
+ const searchResults = await memoryStore.search(queryEmbedding, {
+ projectId: input.projectId,
+ limit: input.limit,
+ });
+ const projectsResult = await projectStore.list();
+ const projects = projectsResult.success ? projectsResult.value : [];
+ const projectMap = new Map(projects.map((project) => [project.id, project]));
+ const resultsWithProjects = searchResults.map((result) => ({
+ ...result,
+ project: projectMap.get(result.projectId) ?? null,
+ }));
+
+ return { results: resultsWithProjects, projects };
+ }),
});
diff --git a/packages/web/src/server/api/routers/project.ts b/packages/web/src/server/api/routers/project.ts
new file mode 100644
index 0000000..c76070d
--- /dev/null
+++ b/packages/web/src/server/api/routers/project.ts
@@ -0,0 +1,79 @@
+import { SupabaseProjectStore } from "@imemio/sdk";
+import { z } from "zod";
+import { createClient } from "~/lib/supabase/server";
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+
+const globalProjectName = "global";
+export const projectRouter = createTRPCRouter({
+ list: protectedProcedure.query(async ({ ctx: context }) => {
+ const supabaseClient = await createClient();
+ const projectStore = new SupabaseProjectStore(
+ supabaseClient,
+ context.user.id,
+ );
+ const result = await projectStore.list();
+
+ if (!result.success) {
+ return [];
+ }
+
+ return result.value;
+ }),
+
+ create: protectedProcedure
+ .input(
+ z.object({
+ name: z.string().min(1),
+ description: z.string().optional(),
+ isGlobal: z.boolean().optional().default(false),
+ }),
+ )
+ .mutation(async ({ ctx: context, input }) => {
+ const supabaseClient = await createClient();
+ const projectStore = new SupabaseProjectStore(
+ supabaseClient,
+ context.user.id,
+ );
+ const result = await projectStore.create({
+ name: input.name,
+ description: input.description,
+ isGlobal: input.isGlobal,
+ });
+
+ if (!result.success) {
+ throw new Error("Failed to create project");
+ }
+
+ return result.value;
+ }),
+
+ getOrCreateGlobal: protectedProcedure.query(async ({ ctx: context }) => {
+ const supabaseClient = await createClient();
+ const projectStore = new SupabaseProjectStore(
+ supabaseClient,
+ context.user.id,
+ );
+ const listResult = await projectStore.list();
+
+ if (listResult.success) {
+ const existingGlobal = listResult.value.find(
+ (project) => project.isGlobal,
+ );
+
+ if (existingGlobal) {
+ return existingGlobal;
+ }
+ }
+
+ const createResult = await projectStore.create({
+ name: globalProjectName,
+ isGlobal: true,
+ });
+
+ if (!createResult.success) {
+ throw new Error("Failed to create global project");
+ }
+
+ return createResult.value;
+ }),
+});