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:
Deshraj Yadav
2025-05-13 08:30:59 -07:00
committed by GitHub
parent 8d61d73d2f
commit f51b39db91
172 changed files with 17846 additions and 0 deletions

View File

@@ -184,6 +184,14 @@
} }
] ]
}, },
{
"tab": "OpenMemory",
"icon": "square-terminal",
"pages": [
"openmemory/overview",
"openmemory/quickstart"
]
},
{ {
"tab": "Examples", "tab": "Examples",
"groups": [ "groups": [

View File

@@ -0,0 +1,81 @@
---
title: Overview
icon: "info"
iconType: "solid"
---
<Snippet file="paper-release.mdx" />
OpenMemory is a local memory infrastructure powered by Mem0 that lets you carry your memory accross any AI app. It provides a unified memory layer that stays with you, enabling agents and assistants to remember what matters across applications.
<img src="https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b" alt="OpenMemory UI" />
## What is the OpenMemory MCP Server
The OpenMemory MCP Server is a private, local-first memory server that creates a shared, persistent memory layer for your MCP-compatible tools. This runs entirely on your machine, enabling seamless context handoff across tools. Whether you're switching between development, planning, or debugging environments, your AI assistants can access relevant memory without needing repeated instructions.
The OpenMemory MCP Server ensures all memory stays local, structured, and under your control with no cloud sync or external storage.
## How the OpenMemory MCP Server Works
Built around the Model Context Protocol (MCP), the OpenMemory MCP Server exposes a standardized set of memory tools:
- `add_memories`: Store new memory objects
- `search_memory`: Retrieve relevant memories
- `list_memories`: View all stored memory
- `delete_all_memories`: Clear memory entirely
Any MCP-compatible tool can connect to the server and use these APIs to persist and access memory.
## What It Enables
### Cross-Client Memory Access
Store context in Cursor and retrieve it later in Claude or Windsurf without repeating yourself.
### Fully Local Memory Store
All memory is stored on your machine. Nothing goes to the cloud. You maintain full ownership and control.
### Unified Memory UI
The built-in OpenMemory dashboard provides a central view of everything stored. Add, browse, delete and control memory access to clients directly from the dashboard.
## Supported Clients
The OpenMemory MCP Server is compatible with any client that supports the Model Context Protocol. This includes:
- Cursor
- Claude Desktop
- Windsurf
- Cline, and more.
As more AI systems adopt MCP, your private memory becomes more valuable.
## Real-World Examples
### Scenario 1: Cross-Tool Project Flow
Define technical requirements of a project in Claude Desktop. Build in Cursor. Debug issues in Windsurf - all with shared context passed through OpenMemory.
### Scenario 2: Preferences That Persist
Set your preferred code style or tone in one tool. When you switch to another MCP client, it can access those same preferences without redefining them.
### Scenario 3: Project Knowledge
Save important project details once, then access them from any compatible AI tool, no more repetitive explanations.
## Conclusion
The OpenMemory MCP Server brings memory to MCP-compatible tools without giving up control or privacy. It solves a foundational limitation in modern LLM workflows: the loss of context across tools, sessions, and environments.
By standardizing memory operations and keeping all data local, it reduces token overhead, improves performance, and unlocks more intelligent interactions across the growing ecosystem of AI assistants.
This is just the beginning. The MCP server is the first core layer in the OpenMemory platform - a broader effort to make memory portable, private, and interoperable across AI systems.
## Getting Started Today
- Github Repository: https://github.com/mem0ai/mem0
- Read the documentation: [Docs Link]
- Join our community: [Discord link]
With OpenMemory, your AI memories stay private, portable, and under your control, exactly where they belong.
OpenMemory: Your memories, your control.
## Contributing
OpenMemory is open source and we welcome contributions. Please see the [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/openmemory/CONTRIBUTING.md) file for more information.

View File

@@ -0,0 +1,47 @@
---
title: Quickstart
icon: "terminal"
iconType: "solid"
---
<Snippet file="paper-release.mdx" />
## Setting Up OpenMemory
Getting started with OpenMemory is straight forward and takes just a few minutes to set up on your local machine. Follow these steps:
### Getting started
First clone the repository and then follow the instructions:
```bash
# Clone the repository
git clone https://github.com/mem0ai/mem0.git
cd mem0/openmemory
# Create the backend .env file with your OpenAI key
pushd api && echo "OPENAI_API_KEY=your_key_here" > .env && popd
# Build the Docker images
make build
# Start all services (API server, vector database, and MCP server components)
make up
# Start the frontend
cp ui/.env.example ui/.env
make ui
```
You can configure the MCP client using the following command (replace username with your username):
```bash
npx install-mcp i "http://localhost:8765/mcp/cursor/sse/username" --client cursor
```
The OpenMemory dashboard will be available at http://localhost:3000. From here, you can view and manage your memories, as well as check connection status with your MCP clients.
Once set up, OpenMemory runs locally on your machine, ensuring all your AI memories remain private and secure while being accessible across any compatible MCP client.
### Getting Started Today
- Github Repository: https://github.com/mem0ai/mem0/openmemory

13
openmemory/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
*.db
.env*
!.env.example
!.env.dev
!ui/lib
.venv/
__pycache__
.DS_Store
node_modules/
*.log
api/.openmemory*
**/.next
.openmemory/

View File

@@ -0,0 +1,70 @@
# Contributing to OpenMemory
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
## Ways to Contribute
We welcome all forms of contributions:
- Bug reports and feature requests through GitHub Issues
- Documentation improvements
- Code contributions
- Testing and feedback
- Community support and discussions
## Development Workflow
1. Fork the repository
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
5. Open a Pull Request
## Development Setup
### Backend Setup
```bash
# Copy environment file and edit file to update OPENAI_API_KEY and other secrets
make env
# Build the containers
make build
# Start the services
make up
```
### Frontend Setup
The frontend is a React application. To start the frontend:
```bash
# Install dependencies and start the development server
make ui-dev
```
### Prerequisites
- Docker and Docker Compose
- Python 3.9+ (for backend development)
- Node.js (for frontend development)
- OpenAI API Key (for LLM interactions)
### Getting Started
Follow the setup instructions in the README.md file to set up your development environment.
## Code Standards
We value:
- Clean, well-documented code
- Thoughtful discussions about features and improvements
- Respectful and constructive feedback
- A welcoming environment for all contributors
## Pull Request Process
1. Ensure your code follows the project's coding standards
2. Update documentation as needed
3. Include tests for new features
4. Make sure all tests pass before submitting
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.

70
openmemory/Makefile Normal file
View File

@@ -0,0 +1,70 @@
.PHONY: help up down logs shell migrate test test-clean env ui-install ui-start ui-dev
NEXT_PUBLIC_USER_ID=$(USER)
NEXT_PUBLIC_API_URL=http://localhost:8765
# Default target
help:
@echo "Available commands:"
@echo " make env - Copy .env.example to .env"
@echo " make up - Start the containers"
@echo " make down - Stop the containers"
@echo " make logs - Show container logs"
@echo " make shell - Open a shell in the api container"
@echo " make migrate - Run database migrations"
@echo " make test - Run tests in a new container"
@echo " make test-clean - Run tests and clean up volumes"
@echo " make ui-install - Install frontend dependencies"
@echo " make ui-start - Start the frontend development server"
@echo " make ui - Install dependencies and start the frontend"
env:
cd api && cp .env.example .env
build:
cd api && docker-compose build
up:
cd api && docker-compose up
down:
cd api && docker-compose down -v
rm -f api/openmemory.db
logs:
cd api && docker-compose logs -f
shell:
cd api && docker-compose exec api bash
upgrade:
cd api && docker-compose exec api alembic upgrade head
migrate:
cd api && docker-compose exec api alembic upgrade head
downgrade:
cd api && docker-compose exec api alembic downgrade -1
test:
cd api && docker-compose run --rm api pytest tests/ -v
test-clean:
cd api && docker-compose run --rm api pytest tests/ -v && docker-compose down -v
# Frontend commands
ui-install:
cd ui && pnpm install
ui-build:
cd ui && pnpm build
ui-start:
cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) pnpm start
ui-dev-start:
cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) && pnpm dev
ui-dev: ui-install ui-dev-start
ui: ui-install ui-build ui-start

51
openmemory/README.md Normal file
View File

@@ -0,0 +1,51 @@
# OpenMemory
OpenMemory is your personal memory layer for LLMs - private, portable, and open-source. Your memories live locally, giving you complete control over your data. Build AI applications with personalized memories while keeping your data secure.
![OpenMemory](https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b)
## Prerequisites
- Docker and Docker Compose
- Python 3.9+ (for backend development)
- Node.js (for frontend development)
- OpenAI API Key (required for LLM interactions)
## Quickstart
You can run the project using the following two commands:
```bash
make build # builds the mcp server
make up # runs openmemory mcp server
make ui # runs openmemory ui
```
After running these commands, you will have:
- OpenMemory MCP server running at: http://localhost:8765 (API documentation available at http://localhost:8765/docs)
- OpenMemory UI running at: http://localhost:3000
## Project Structure
- `api/` - Backend APIs + MCP server
- `ui/` - Frontend React application
## Contributing
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
We welcome all forms of contributions:
- Bug reports and feature requests
- Documentation improvements
- Code contributions
- Testing and feedback
- Community support
How to contribute:
1. Fork the repository
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
5. Open a Pull Request
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.

View File

@@ -0,0 +1,2 @@
OPENAI_API_KEY=sk-...
USER=username

View File

@@ -0,0 +1 @@
3.12

13
openmemory/api/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.name="mem0/openmemory-mcp"
WORKDIR /usr/src/openmemory
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8765
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"]

53
openmemory/api/README.md Normal file
View File

@@ -0,0 +1,53 @@
# OpenMemory API
This directory contains the backend API for OpenMemory, built with FastAPI and SQLAlchemy. This also runs the Mem0 MCP Server that you can use with MCP clients to remember things.
## Quick Start with Docker (Recommended)
The easiest way to get started is using Docker. Make sure you have Docker and Docker Compose installed.
1. Build the containers:
```bash
make build
```
2. Start the services:
```bash
make up
```
The API will be available at `http://localhost:8765`
### Common Docker Commands
- View logs: `make logs`
- Open shell in container: `make shell`
- Run database migrations: `make migrate`
- Run tests: `make test`
- Run tests and clean up: `make test-clean`
- Stop containers: `make down`
## API Documentation
Once the server is running, you can access the API documentation at:
- Swagger UI: `http://localhost:8765/docs`
- ReDoc: `http://localhost:8765/redoc`
## Project Structure
- `app/`: Main application code
- `models.py`: Database models
- `database.py`: Database configuration
- `routers/`: API route handlers
- `migrations/`: Database migration files
- `tests/`: Test files
- `alembic/`: Alembic migration configuration
- `main.py`: Application entry point
## Development Guidelines
- Follow PEP 8 style guide
- Use type hints
- Write tests for new features
- Update documentation when making changes
- Run migrations for database changes

114
openmemory/api/alembic.ini Normal file
View File

@@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./openmemory.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,92 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
from dotenv import load_dotenv
# Add the parent directory to the Python path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load environment variables
load_dotenv()
# Import your models here
from app.database import Base
from app.models import * # Import all your models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,226 @@
"""Initial migration
Revision ID: 0b53c747049a
Revises:
Create Date: 2025-04-19 00:59:56.244203
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0b53c747049a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('access_controls',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('subject_type', sa.String(), nullable=False),
sa.Column('subject_id', sa.UUID(), nullable=True),
sa.Column('object_type', sa.String(), nullable=False),
sa.Column('object_id', sa.UUID(), nullable=True),
sa.Column('effect', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_access_object', 'access_controls', ['object_type', 'object_id'], unique=False)
op.create_index('idx_access_subject', 'access_controls', ['subject_type', 'subject_id'], unique=False)
op.create_index(op.f('ix_access_controls_created_at'), 'access_controls', ['created_at'], unique=False)
op.create_index(op.f('ix_access_controls_effect'), 'access_controls', ['effect'], unique=False)
op.create_index(op.f('ix_access_controls_object_id'), 'access_controls', ['object_id'], unique=False)
op.create_index(op.f('ix_access_controls_object_type'), 'access_controls', ['object_type'], unique=False)
op.create_index(op.f('ix_access_controls_subject_id'), 'access_controls', ['subject_id'], unique=False)
op.create_index(op.f('ix_access_controls_subject_type'), 'access_controls', ['subject_type'], unique=False)
op.create_table('archive_policies',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('criteria_type', sa.String(), nullable=False),
sa.Column('criteria_id', sa.UUID(), nullable=True),
sa.Column('days_to_archive', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_policy_criteria', 'archive_policies', ['criteria_type', 'criteria_id'], unique=False)
op.create_index(op.f('ix_archive_policies_created_at'), 'archive_policies', ['created_at'], unique=False)
op.create_index(op.f('ix_archive_policies_criteria_id'), 'archive_policies', ['criteria_id'], unique=False)
op.create_index(op.f('ix_archive_policies_criteria_type'), 'archive_policies', ['criteria_type'], unique=False)
op.create_table('categories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_categories_created_at'), 'categories', ['created_at'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=True)
op.create_table('apps',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False)
op.create_index(op.f('ix_apps_is_active'), 'apps', ['is_active'], unique=False)
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=True)
op.create_index(op.f('ix_apps_owner_id'), 'apps', ['owner_id'], unique=False)
op.create_table('memories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('app_id', sa.UUID(), nullable=False),
sa.Column('content', sa.String(), nullable=False),
sa.Column('vector', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('archived_at', sa.DateTime(), nullable=True),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_memory_app_state', 'memories', ['app_id', 'state'], unique=False)
op.create_index('idx_memory_user_app', 'memories', ['user_id', 'app_id'], unique=False)
op.create_index('idx_memory_user_state', 'memories', ['user_id', 'state'], unique=False)
op.create_index(op.f('ix_memories_app_id'), 'memories', ['app_id'], unique=False)
op.create_index(op.f('ix_memories_archived_at'), 'memories', ['archived_at'], unique=False)
op.create_index(op.f('ix_memories_created_at'), 'memories', ['created_at'], unique=False)
op.create_index(op.f('ix_memories_deleted_at'), 'memories', ['deleted_at'], unique=False)
op.create_index(op.f('ix_memories_state'), 'memories', ['state'], unique=False)
op.create_index(op.f('ix_memories_user_id'), 'memories', ['user_id'], unique=False)
op.create_table('memory_access_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('app_id', sa.UUID(), nullable=False),
sa.Column('accessed_at', sa.DateTime(), nullable=True),
sa.Column('access_type', sa.String(), nullable=False),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_access_app_time', 'memory_access_logs', ['app_id', 'accessed_at'], unique=False)
op.create_index('idx_access_memory_time', 'memory_access_logs', ['memory_id', 'accessed_at'], unique=False)
op.create_index(op.f('ix_memory_access_logs_access_type'), 'memory_access_logs', ['access_type'], unique=False)
op.create_index(op.f('ix_memory_access_logs_accessed_at'), 'memory_access_logs', ['accessed_at'], unique=False)
op.create_index(op.f('ix_memory_access_logs_app_id'), 'memory_access_logs', ['app_id'], unique=False)
op.create_index(op.f('ix_memory_access_logs_memory_id'), 'memory_access_logs', ['memory_id'], unique=False)
op.create_table('memory_categories',
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('memory_id', 'category_id')
)
op.create_index('idx_memory_category', 'memory_categories', ['memory_id', 'category_id'], unique=False)
op.create_index(op.f('ix_memory_categories_category_id'), 'memory_categories', ['category_id'], unique=False)
op.create_index(op.f('ix_memory_categories_memory_id'), 'memory_categories', ['memory_id'], unique=False)
op.create_table('memory_status_history',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('changed_by', sa.UUID(), nullable=False),
sa.Column('old_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
sa.Column('new_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
sa.Column('changed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_history_memory_state', 'memory_status_history', ['memory_id', 'new_state'], unique=False)
op.create_index('idx_history_user_time', 'memory_status_history', ['changed_by', 'changed_at'], unique=False)
op.create_index(op.f('ix_memory_status_history_changed_at'), 'memory_status_history', ['changed_at'], unique=False)
op.create_index(op.f('ix_memory_status_history_changed_by'), 'memory_status_history', ['changed_by'], unique=False)
op.create_index(op.f('ix_memory_status_history_memory_id'), 'memory_status_history', ['memory_id'], unique=False)
op.create_index(op.f('ix_memory_status_history_new_state'), 'memory_status_history', ['new_state'], unique=False)
op.create_index(op.f('ix_memory_status_history_old_state'), 'memory_status_history', ['old_state'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_memory_status_history_old_state'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_new_state'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_memory_id'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_changed_by'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_changed_at'), table_name='memory_status_history')
op.drop_index('idx_history_user_time', table_name='memory_status_history')
op.drop_index('idx_history_memory_state', table_name='memory_status_history')
op.drop_table('memory_status_history')
op.drop_index(op.f('ix_memory_categories_memory_id'), table_name='memory_categories')
op.drop_index(op.f('ix_memory_categories_category_id'), table_name='memory_categories')
op.drop_index('idx_memory_category', table_name='memory_categories')
op.drop_table('memory_categories')
op.drop_index(op.f('ix_memory_access_logs_memory_id'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_app_id'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_accessed_at'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_access_type'), table_name='memory_access_logs')
op.drop_index('idx_access_memory_time', table_name='memory_access_logs')
op.drop_index('idx_access_app_time', table_name='memory_access_logs')
op.drop_table('memory_access_logs')
op.drop_index(op.f('ix_memories_user_id'), table_name='memories')
op.drop_index(op.f('ix_memories_state'), table_name='memories')
op.drop_index(op.f('ix_memories_deleted_at'), table_name='memories')
op.drop_index(op.f('ix_memories_created_at'), table_name='memories')
op.drop_index(op.f('ix_memories_archived_at'), table_name='memories')
op.drop_index(op.f('ix_memories_app_id'), table_name='memories')
op.drop_index('idx_memory_user_state', table_name='memories')
op.drop_index('idx_memory_user_app', table_name='memories')
op.drop_index('idx_memory_app_state', table_name='memories')
op.drop_table('memories')
op.drop_index(op.f('ix_apps_owner_id'), table_name='apps')
op.drop_index(op.f('ix_apps_name'), table_name='apps')
op.drop_index(op.f('ix_apps_is_active'), table_name='apps')
op.drop_index(op.f('ix_apps_created_at'), table_name='apps')
op.drop_table('apps')
op.drop_index(op.f('ix_users_user_id'), table_name='users')
op.drop_index(op.f('ix_users_name'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_created_at'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_created_at'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_archive_policies_criteria_type'), table_name='archive_policies')
op.drop_index(op.f('ix_archive_policies_criteria_id'), table_name='archive_policies')
op.drop_index(op.f('ix_archive_policies_created_at'), table_name='archive_policies')
op.drop_index('idx_policy_criteria', table_name='archive_policies')
op.drop_table('archive_policies')
op.drop_index(op.f('ix_access_controls_subject_type'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_subject_id'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_object_type'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_object_id'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_effect'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_created_at'), table_name='access_controls')
op.drop_index('idx_access_subject', table_name='access_controls')
op.drop_index('idx_access_object', table_name='access_controls')
op.drop_table('access_controls')
# ### end Alembic commands ###

View File

@@ -0,0 +1 @@
# This file makes the app directory a Python package

View File

@@ -0,0 +1,4 @@
import os
USER_ID = os.getenv("USER", "default_user")
DEFAULT_APP_ID = "openmemory"

View File

@@ -0,0 +1,29 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from dotenv import load_dotenv
# load .env file (make sure you have DATABASE_URL set)
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL is not set in environment")
# SQLAlchemy engine & session
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
# Dependency for FastAPI
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,382 @@
import logging
import json
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from app.utils.memory import get_memory_client
from fastapi import FastAPI, Request
from fastapi.routing import APIRouter
import contextvars
import os
from dotenv import load_dotenv
from app.database import SessionLocal
from app.models import Memory, MemoryState, MemoryStatusHistory, MemoryAccessLog
from app.utils.db import get_user_and_app
import uuid
import datetime
from app.utils.permissions import check_memory_access_permissions
from qdrant_client import models as qdrant_models
# Load environment variables
load_dotenv()
# Initialize MCP and memory client
mcp = FastMCP("mem0-mcp-server")
# Check if OpenAI API key is set
if not os.getenv("OPENAI_API_KEY"):
raise Exception("OPENAI_API_KEY is not set in .env file")
memory_client = get_memory_client()
# Context variables for user_id and client_name
user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id")
client_name_var: contextvars.ContextVar[str] = contextvars.ContextVar("client_name")
# Create a router for MCP endpoints
mcp_router = APIRouter(prefix="/mcp")
# Initialize SSE transport
sse = SseServerTransport("/mcp/messages/")
@mcp.tool(description="Add new memories to the user's memory")
async def add_memories(text: str) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Check if app is active
if not app.is_active:
return f"Error: App {app.name} is currently paused on OpenMemory. Cannot create new memories."
response = memory_client.add(text,
user_id=uid,
metadata={
"source_app": "openmemory",
"mcp_client": client_name,
})
# Process the response and update database
if isinstance(response, dict) and 'results' in response:
for result in response['results']:
memory_id = uuid.UUID(result['id'])
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if result['event'] == 'ADD':
if not memory:
memory = Memory(
id=memory_id,
user_id=user.id,
app_id=app.id,
content=result['memory'],
state=MemoryState.active
)
db.add(memory)
else:
memory.state = MemoryState.active
memory.content = result['memory']
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.deleted if memory else None,
new_state=MemoryState.active
)
db.add(history)
elif result['event'] == 'DELETE':
if memory:
memory.state = MemoryState.deleted
memory.deleted_at = datetime.datetime.now(datetime.UTC)
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
db.commit()
return response
finally:
db.close()
except Exception as e:
return f"Error adding to memory: {e}"
@mcp.tool(description="Search the user's memory for memories that match the query")
async def search_memory(query: str) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Get accessible memory IDs based on ACL
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
conditions = [qdrant_models.FieldCondition(key="user_id", match=qdrant_models.MatchValue(value=uid))]
logging.info(f"Accessible memory IDs: {accessible_memory_ids}")
logging.info(f"Conditions: {conditions}")
if accessible_memory_ids:
# Convert UUIDs to strings for Qdrant
accessible_memory_ids_str = [str(memory_id) for memory_id in accessible_memory_ids]
conditions.append(qdrant_models.HasIdCondition(has_id=accessible_memory_ids_str))
filters = qdrant_models.Filter(must=conditions)
logging.info(f"Filters: {filters}")
embeddings = memory_client.embedding_model.embed(query, "search")
hits = memory_client.vector_store.client.query_points(
collection_name=memory_client.vector_store.collection_name,
query=embeddings,
query_filter=filters,
limit=10,
)
memories = hits.points
memories = [
{
"id": memory.id,
"memory": memory.payload["data"],
"hash": memory.payload.get("hash"),
"created_at": memory.payload.get("created_at"),
"updated_at": memory.payload.get("updated_at"),
"score": memory.score,
}
for memory in memories
]
# Log memory access for each memory found
if isinstance(memories, dict) and 'results' in memories:
print(f"Memories: {memories}")
for memory_data in memories['results']:
if 'id' in memory_data:
memory_id = uuid.UUID(memory_data['id'])
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="search",
metadata_={
"query": query,
"score": memory_data.get('score'),
"hash": memory_data.get('hash')
}
)
db.add(access_log)
db.commit()
else:
for memory in memories:
memory_id = uuid.UUID(memory['id'])
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="search",
metadata_={
"query": query,
"score": memory.get('score'),
"hash": memory.get('hash')
}
)
db.add(access_log)
db.commit()
return json.dumps(memories, indent=2)
finally:
db.close()
except Exception as e:
logging.exception(e)
return f"Error searching memory: {e}"
@mcp.tool(description="List all memories in the user's memory")
async def list_memories() -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Get all memories
memories = memory_client.get_all(user_id=uid)
filtered_memories = []
# Filter memories based on permissions
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
if isinstance(memories, dict) and 'results' in memories:
for memory_data in memories['results']:
if 'id' in memory_data:
memory_id = uuid.UUID(memory_data['id'])
if memory_id in accessible_memory_ids:
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="list",
metadata_={
"hash": memory_data.get('hash')
}
)
db.add(access_log)
filtered_memories.append(memory_data)
db.commit()
else:
for memory in memories:
memory_id = uuid.UUID(memory['id'])
memory_obj = db.query(Memory).filter(Memory.id == memory_id).first()
if memory_obj and check_memory_access_permissions(db, memory_obj, app.id):
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="list",
metadata_={
"hash": memory.get('hash')
}
)
db.add(access_log)
filtered_memories.append(memory)
db.commit()
return json.dumps(filtered_memories, indent=2)
finally:
db.close()
except Exception as e:
return f"Error getting memories: {e}"
@mcp.tool(description="Delete all memories in the user's memory")
async def delete_all_memories() -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
# delete the accessible memories only
for memory_id in accessible_memory_ids:
memory_client.delete(memory_id)
# Update each memory's state and create history entries
now = datetime.datetime.now(datetime.UTC)
for memory_id in accessible_memory_ids:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
# Update memory state
memory.state = MemoryState.deleted
memory.deleted_at = now
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="delete_all",
metadata_={"operation": "bulk_delete"}
)
db.add(access_log)
db.commit()
return "Successfully deleted all memories"
finally:
db.close()
except Exception as e:
return f"Error deleting memories: {e}"
@mcp_router.get("/{client_name}/sse/{user_id}")
async def handle_sse(request: Request):
"""Handle SSE connections for a specific user and client"""
# Extract user_id and client_name from path parameters
uid = request.path_params.get("user_id")
user_token = user_id_var.set(uid or "")
client_name = request.path_params.get("client_name")
client_token = client_name_var.set(client_name or "")
try:
# Handle SSE connection
async with sse.connect_sse(
request.scope,
request.receive,
request._send,
) as (read_stream, write_stream):
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options(),
)
finally:
# Clean up context variables
user_id_var.reset(user_token)
client_name_var.reset(client_token)
@mcp_router.post("/messages/")
async def handle_post_message(request: Request):
"""Handle POST messages for SSE"""
try:
body = await request.body()
# Create a simple receive function that returns the body
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
# Create a simple send function that does nothing
async def send(message):
pass
# Call handle_post_message with the correct arguments
await sse.handle_post_message(request.scope, receive, send)
# Return a success response
return {"status": "ok"}
finally:
pass
# Clean up context variable
# client_name_var.reset(client_token)
def setup_mcp_server(app: FastAPI):
"""Setup MCP server with the FastAPI application"""
mcp._mcp_server.name = f"mem0-mcp-server"
# Include MCP router in the FastAPI app
app.include_router(mcp_router)

View File

@@ -0,0 +1,217 @@
import enum
import uuid
import datetime
from sqlalchemy import (
Column, String, Boolean, ForeignKey, Enum, Table,
DateTime, JSON, Integer, UUID, Index, event
)
from sqlalchemy.orm import relationship
from app.database import Base
from sqlalchemy.orm import Session
from app.utils.categorization import get_categories_for_memory
def get_current_utc_time():
"""Get current UTC time"""
return datetime.datetime.now(datetime.UTC)
class MemoryState(enum.Enum):
active = "active"
paused = "paused"
archived = "archived"
deleted = "deleted"
class User(Base):
__tablename__ = "users"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
user_id = Column(String, nullable=False, unique=True, index=True)
name = Column(String, nullable=True, index=True)
email = Column(String, unique=True, nullable=True, index=True)
metadata_ = Column('metadata', JSON, default=dict)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
apps = relationship("App", back_populates="owner")
memories = relationship("Memory", back_populates="user")
class App(Base):
__tablename__ = "apps"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
owner_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String)
metadata_ = Column('metadata', JSON, default=dict)
is_active = Column(Boolean, default=True, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
owner = relationship("User", back_populates="apps")
memories = relationship("Memory", back_populates="app")
class Memory(Base):
__tablename__ = "memories"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
user_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
content = Column(String, nullable=False)
vector = Column(String)
metadata_ = Column('metadata', JSON, default=dict)
state = Column(Enum(MemoryState), default=MemoryState.active, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
archived_at = Column(DateTime, nullable=True, index=True)
deleted_at = Column(DateTime, nullable=True, index=True)
user = relationship("User", back_populates="memories")
app = relationship("App", back_populates="memories")
categories = relationship("Category", secondary="memory_categories", back_populates="memories")
__table_args__ = (
Index('idx_memory_user_state', 'user_id', 'state'),
Index('idx_memory_app_state', 'app_id', 'state'),
Index('idx_memory_user_app', 'user_id', 'app_id'),
)
class Category(Base):
__tablename__ = "categories"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String)
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC), index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
memories = relationship("Memory", secondary="memory_categories", back_populates="categories")
memory_categories = Table(
"memory_categories", Base.metadata,
Column("memory_id", UUID, ForeignKey("memories.id"), primary_key=True, index=True),
Column("category_id", UUID, ForeignKey("categories.id"), primary_key=True, index=True),
Index('idx_memory_category', 'memory_id', 'category_id')
)
class AccessControl(Base):
__tablename__ = "access_controls"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
subject_type = Column(String, nullable=False, index=True)
subject_id = Column(UUID, nullable=True, index=True)
object_type = Column(String, nullable=False, index=True)
object_id = Column(UUID, nullable=True, index=True)
effect = Column(String, nullable=False, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_access_subject', 'subject_type', 'subject_id'),
Index('idx_access_object', 'object_type', 'object_id'),
)
class ArchivePolicy(Base):
__tablename__ = "archive_policies"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
criteria_type = Column(String, nullable=False, index=True)
criteria_id = Column(UUID, nullable=True, index=True)
days_to_archive = Column(Integer, nullable=False)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_policy_criteria', 'criteria_type', 'criteria_id'),
)
class MemoryStatusHistory(Base):
__tablename__ = "memory_status_history"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
changed_by = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
old_state = Column(Enum(MemoryState), nullable=False, index=True)
new_state = Column(Enum(MemoryState), nullable=False, index=True)
changed_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_history_memory_state', 'memory_id', 'new_state'),
Index('idx_history_user_time', 'changed_by', 'changed_at'),
)
class MemoryAccessLog(Base):
__tablename__ = "memory_access_logs"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
accessed_at = Column(DateTime, default=get_current_utc_time, index=True)
access_type = Column(String, nullable=False, index=True)
metadata_ = Column('metadata', JSON, default=dict)
__table_args__ = (
Index('idx_access_memory_time', 'memory_id', 'accessed_at'),
Index('idx_access_app_time', 'app_id', 'accessed_at'),
)
def categorize_memory(memory: Memory, db: Session) -> None:
"""Categorize a memory using OpenAI and store the categories in the database."""
try:
# Get categories from OpenAI
categories = get_categories_for_memory(memory.content)
# Get or create categories in the database
for category_name in categories:
category = db.query(Category).filter(Category.name == category_name).first()
if not category:
category = Category(
name=category_name,
description=f"Automatically created category for {category_name}"
)
db.add(category)
db.flush() # Flush to get the category ID
# Check if the memory-category association already exists
existing = db.execute(
memory_categories.select().where(
(memory_categories.c.memory_id == memory.id) &
(memory_categories.c.category_id == category.id)
)
).first()
if not existing:
# Create the association
db.execute(
memory_categories.insert().values(
memory_id=memory.id,
category_id=category.id
)
)
db.commit()
except Exception as e:
db.rollback()
print(f"Error categorizing memory: {e}")
@event.listens_for(Memory, 'after_insert')
def after_memory_insert(mapper, connection, target):
"""Trigger categorization after a memory is inserted."""
db = Session(bind=connection)
categorize_memory(target, db)
db.close()
@event.listens_for(Memory, 'after_update')
def after_memory_update(mapper, connection, target):
"""Trigger categorization after a memory is updated."""
db = Session(bind=connection)
categorize_memory(target, db)
db.close()

View File

@@ -0,0 +1,5 @@
from .memories import router as memories_router
from .apps import router as apps_router
from .stats import router as stats_router
__all__ = ["memories_router", "apps_router", "stats_router"]

View File

@@ -0,0 +1,223 @@
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, desc
from app.database import get_db
from app.models import App, Memory, MemoryAccessLog, MemoryState
router = APIRouter(prefix="/api/v1/apps", tags=["apps"])
# Helper functions
def get_app_or_404(db: Session, app_id: UUID) -> App:
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="App not found")
return app
# List all apps with filtering
@router.get("/")
async def list_apps(
name: Optional[str] = None,
is_active: Optional[bool] = None,
sort_by: str = 'name',
sort_direction: str = 'asc',
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Create a subquery for memory counts
memory_counts = db.query(
Memory.app_id,
func.count(Memory.id).label('memory_count')
).filter(
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
).group_by(Memory.app_id).subquery()
# Create a subquery for access counts
access_counts = db.query(
MemoryAccessLog.app_id,
func.count(func.distinct(MemoryAccessLog.memory_id)).label('access_count')
).group_by(MemoryAccessLog.app_id).subquery()
# Base query
query = db.query(
App,
func.coalesce(memory_counts.c.memory_count, 0).label('total_memories_created'),
func.coalesce(access_counts.c.access_count, 0).label('total_memories_accessed')
)
# Join with subqueries
query = query.outerjoin(
memory_counts,
App.id == memory_counts.c.app_id
).outerjoin(
access_counts,
App.id == access_counts.c.app_id
)
if name:
query = query.filter(App.name.ilike(f"%{name}%"))
if is_active is not None:
query = query.filter(App.is_active == is_active)
# Apply sorting
if sort_by == 'name':
sort_field = App.name
elif sort_by == 'memories':
sort_field = func.coalesce(memory_counts.c.memory_count, 0)
elif sort_by == 'memories_accessed':
sort_field = func.coalesce(access_counts.c.access_count, 0)
else:
sort_field = App.name # default sort
if sort_direction == 'desc':
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
total = query.count()
apps = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"apps": [
{
"id": app[0].id,
"name": app[0].name,
"is_active": app[0].is_active,
"total_memories_created": app[1],
"total_memories_accessed": app[2]
}
for app in apps
]
}
# Get app details
@router.get("/{app_id}")
async def get_app_details(
app_id: UUID,
db: Session = Depends(get_db)
):
app = get_app_or_404(db, app_id)
# Get memory access statistics
access_stats = db.query(
func.count(MemoryAccessLog.id).label("total_memories_accessed"),
func.min(MemoryAccessLog.accessed_at).label("first_accessed"),
func.max(MemoryAccessLog.accessed_at).label("last_accessed")
).filter(MemoryAccessLog.app_id == app_id).first()
return {
"is_active": app.is_active,
"total_memories_created": db.query(Memory)
.filter(Memory.app_id == app_id)
.count(),
"total_memories_accessed": access_stats.total_memories_accessed or 0,
"first_accessed": access_stats.first_accessed,
"last_accessed": access_stats.last_accessed
}
# List memories created by app
@router.get("/{app_id}/memories")
async def list_app_memories(
app_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
get_app_or_404(db, app_id)
query = db.query(Memory).filter(
Memory.app_id == app_id,
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
)
# Add eager loading for categories
query = query.options(joinedload(Memory.categories))
total = query.count()
memories = query.order_by(Memory.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"memories": [
{
"id": memory.id,
"content": memory.content,
"created_at": memory.created_at,
"state": memory.state.value,
"app_id": memory.app_id,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
}
for memory in memories
]
}
# List memories accessed by app
@router.get("/{app_id}/accessed")
async def list_app_accessed_memories(
app_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Get memories with access counts
query = db.query(
Memory,
func.count(MemoryAccessLog.id).label("access_count")
).join(
MemoryAccessLog,
Memory.id == MemoryAccessLog.memory_id
).filter(
MemoryAccessLog.app_id == app_id
).group_by(
Memory.id
).order_by(
desc("access_count")
)
# Add eager loading for categories
query = query.options(joinedload(Memory.categories))
total = query.count()
results = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"memories": [
{
"memory": {
"id": memory.id,
"content": memory.content,
"created_at": memory.created_at,
"state": memory.state.value,
"app_id": memory.app_id,
"app_name": memory.app.name if memory.app else None,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
},
"access_count": count
}
for memory, count in results
]
}
@router.put("/{app_id}")
async def update_app_details(
app_id: UUID,
is_active: bool,
db: Session = Depends(get_db)
):
app = get_app_or_404(db, app_id)
app.is_active = is_active
db.commit()
return {"status": "success", "message": "Updated app details successfully"}

View File

@@ -0,0 +1,575 @@
from datetime import datetime, UTC
from typing import List, Optional, Set
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from fastapi_pagination import Page, Params
from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate
from pydantic import BaseModel
from sqlalchemy import or_, func
from app.database import get_db
from app.models import (
Memory, MemoryState, MemoryAccessLog, App,
MemoryStatusHistory, User, Category, AccessControl
)
from app.schemas import MemoryResponse, PaginatedMemoryResponse
from app.utils.permissions import check_memory_access_permissions
router = APIRouter(prefix="/api/v1/memories", tags=["memories"])
def get_memory_or_404(db: Session, memory_id: UUID) -> Memory:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if not memory:
raise HTTPException(status_code=404, detail="Memory not found")
return memory
def update_memory_state(db: Session, memory_id: UUID, new_state: MemoryState, user_id: UUID):
memory = get_memory_or_404(db, memory_id)
old_state = memory.state
# Update memory state
memory.state = new_state
if new_state == MemoryState.archived:
memory.archived_at = datetime.now(UTC)
elif new_state == MemoryState.deleted:
memory.deleted_at = datetime.now(UTC)
# Record state change
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user_id,
old_state=old_state,
new_state=new_state
)
db.add(history)
db.commit()
return memory
def get_accessible_memory_ids(db: Session, app_id: UUID) -> Set[UUID]:
"""
Get the set of memory IDs that the app has access to based on app-level ACL rules.
Returns all memory IDs if no specific restrictions are found.
"""
# Get app-level access controls
app_access = db.query(AccessControl).filter(
AccessControl.subject_type == "app",
AccessControl.subject_id == app_id,
AccessControl.object_type == "memory"
).all()
# If no app-level rules exist, return None to indicate all memories are accessible
if not app_access:
return None
# Initialize sets for allowed and denied memory IDs
allowed_memory_ids = set()
denied_memory_ids = set()
# Process app-level rules
for rule in app_access:
if rule.effect == "allow":
if rule.object_id: # Specific memory access
allowed_memory_ids.add(rule.object_id)
else: # All memories access
return None # All memories allowed
elif rule.effect == "deny":
if rule.object_id: # Specific memory denied
denied_memory_ids.add(rule.object_id)
else: # All memories denied
return set() # No memories accessible
# Remove denied memories from allowed set
if allowed_memory_ids:
allowed_memory_ids -= denied_memory_ids
return allowed_memory_ids
# List all memories with filtering
@router.get("/", response_model=Page[MemoryResponse])
async def list_memories(
user_id: str,
app_id: Optional[UUID] = None,
from_date: Optional[int] = Query(
None,
description="Filter memories created after this date (timestamp)",
examples=[1718505600]
),
to_date: Optional[int] = Query(
None,
description="Filter memories created before this date (timestamp)",
examples=[1718505600]
),
categories: Optional[str] = None,
params: Params = Depends(),
search_query: Optional[str] = None,
sort_column: Optional[str] = Query(None, description="Column to sort by (memory, categories, app_name, created_at)"),
sort_direction: Optional[str] = Query(None, description="Sort direction (asc or desc)"),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Build base query
query = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived,
Memory.content.ilike(f"%{search_query}%") if search_query else True
)
# Apply filters
if app_id:
query = query.filter(Memory.app_id == app_id)
if from_date:
from_datetime = datetime.fromtimestamp(from_date, tz=UTC)
query = query.filter(Memory.created_at >= from_datetime)
if to_date:
to_datetime = datetime.fromtimestamp(to_date, tz=UTC)
query = query.filter(Memory.created_at <= to_datetime)
# Add joins for app and categories after filtering
query = query.outerjoin(App, Memory.app_id == App.id)
query = query.outerjoin(Memory.categories)
# Apply category filter if provided
if categories:
category_list = [c.strip() for c in categories.split(",")]
query = query.filter(Category.name.in_(category_list))
# Apply sorting if specified
if sort_column:
sort_field = getattr(Memory, sort_column, None)
if sort_field:
query = query.order_by(sort_field.desc()) if sort_direction == "desc" else query.order_by(sort_field.asc())
# Get paginated results
paginated_results = sqlalchemy_paginate(query, params)
# Filter results based on permissions
filtered_items = []
for item in paginated_results.items:
if check_memory_access_permissions(db, item, app_id):
filtered_items.append(item)
# Update paginated results with filtered items
paginated_results.items = filtered_items
paginated_results.total = len(filtered_items)
return paginated_results
# Get all categories
@router.get("/categories")
async def get_categories(
user_id: str,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get unique categories associated with the user's memories
# Get all memories
memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted, Memory.state != MemoryState.archived).all()
# Get all categories from memories
categories = [category for memory in memories for category in memory.categories]
# Get unique categories
unique_categories = list(set(categories))
return {
"categories": unique_categories,
"total": len(unique_categories)
}
class CreateMemoryRequest(BaseModel):
user_id: str
text: str
metadata: dict = {}
infer: bool = True
app: str = "openmemory"
# Create new memory
@router.post("/")
async def create_memory(
request: CreateMemoryRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get or create app
app_obj = db.query(App).filter(App.name == request.app).first()
if not app_obj:
app_obj = App(name=request.app, owner_id=user.id)
db.add(app_obj)
db.commit()
db.refresh(app_obj)
# Check if app is active
if not app_obj.is_active:
raise HTTPException(status_code=403, detail=f"App {request.app} is currently paused on OpenMemory. Cannot create new memories.")
# Create memory
memory = Memory(
user_id=user.id,
app_id=app_obj.id,
content=request.text,
metadata_=request.metadata
)
db.add(memory)
db.commit()
db.refresh(memory)
return memory
# Get memory by ID
@router.get("/{memory_id}")
async def get_memory(
memory_id: UUID,
db: Session = Depends(get_db)
):
memory = get_memory_or_404(db, memory_id)
return {
"id": memory.id,
"text": memory.content,
"created_at": int(memory.created_at.timestamp()),
"state": memory.state.value,
"app_id": memory.app_id,
"app_name": memory.app.name if memory.app else None,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
}
class DeleteMemoriesRequest(BaseModel):
memory_ids: List[UUID]
user_id: str
# Delete multiple memories
@router.delete("/")
async def delete_memories(
request: DeleteMemoriesRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
for memory_id in request.memory_ids:
update_memory_state(db, memory_id, MemoryState.deleted, user.id)
return {"message": f"Successfully deleted {len(request.memory_ids)} memories"}
# Archive memories
@router.post("/actions/archive")
async def archive_memories(
memory_ids: List[UUID],
user_id: UUID,
db: Session = Depends(get_db)
):
for memory_id in memory_ids:
update_memory_state(db, memory_id, MemoryState.archived, user_id)
return {"message": f"Successfully archived {len(memory_ids)} memories"}
class PauseMemoriesRequest(BaseModel):
memory_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
app_id: Optional[UUID] = None
all_for_app: bool = False
global_pause: bool = False
state: Optional[MemoryState] = None
user_id: str
# Pause access to memories
@router.post("/actions/pause")
async def pause_memories(
request: PauseMemoriesRequest,
db: Session = Depends(get_db)
):
global_pause = request.global_pause
all_for_app = request.all_for_app
app_id = request.app_id
memory_ids = request.memory_ids
category_ids = request.category_ids
state = request.state or MemoryState.paused
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_id = user.id
if global_pause:
# Pause all memories
memories = db.query(Memory).filter(
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": "Successfully paused all memories"}
if app_id:
# Pause all memories for an app
memories = db.query(Memory).filter(
Memory.app_id == app_id,
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused all memories for app {app_id}"}
if all_for_app and memory_ids:
# Pause all memories for an app
memories = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.id.in_(memory_ids)
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused all memories"}
if memory_ids:
# Pause specific memories
for memory_id in memory_ids:
update_memory_state(db, memory_id, state, user_id)
return {"message": f"Successfully paused {len(memory_ids)} memories"}
if category_ids:
# Pause memories by category
memories = db.query(Memory).join(Memory.categories).filter(
Category.id.in_(category_ids),
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused memories in {len(category_ids)} categories"}
raise HTTPException(status_code=400, detail="Invalid pause request parameters")
# Get memory access logs
@router.get("/{memory_id}/access-log")
async def get_memory_access_log(
memory_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
query = db.query(MemoryAccessLog).filter(MemoryAccessLog.memory_id == memory_id)
total = query.count()
logs = query.order_by(MemoryAccessLog.accessed_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Get app name
for log in logs:
app = db.query(App).filter(App.id == log.app_id).first()
log.app_name = app.name if app else None
return {
"total": total,
"page": page,
"page_size": page_size,
"logs": logs
}
class UpdateMemoryRequest(BaseModel):
memory_content: str
user_id: str
# Update a memory
@router.put("/{memory_id}")
async def update_memory(
memory_id: UUID,
request: UpdateMemoryRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
memory = get_memory_or_404(db, memory_id)
memory.content = request.memory_content
db.commit()
db.refresh(memory)
return memory
class FilterMemoriesRequest(BaseModel):
user_id: str
page: int = 1
size: int = 10
search_query: Optional[str] = None
app_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
sort_column: Optional[str] = None
sort_direction: Optional[str] = None
from_date: Optional[int] = None
to_date: Optional[int] = None
show_archived: Optional[bool] = False
@router.post("/filter", response_model=Page[MemoryResponse])
async def filter_memories(
request: FilterMemoriesRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Build base query
query = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
)
# Filter archived memories based on show_archived parameter
if not request.show_archived:
query = query.filter(Memory.state != MemoryState.archived)
# Apply search filter
if request.search_query:
query = query.filter(Memory.content.ilike(f"%{request.search_query}%"))
# Apply app filter
if request.app_ids:
query = query.filter(Memory.app_id.in_(request.app_ids))
# Add joins for app and categories
query = query.outerjoin(App, Memory.app_id == App.id)
# Apply category filter
if request.category_ids:
query = query.join(Memory.categories).filter(Category.id.in_(request.category_ids))
else:
query = query.outerjoin(Memory.categories)
# Apply date filters
if request.from_date:
from_datetime = datetime.fromtimestamp(request.from_date, tz=UTC)
query = query.filter(Memory.created_at >= from_datetime)
if request.to_date:
to_datetime = datetime.fromtimestamp(request.to_date, tz=UTC)
query = query.filter(Memory.created_at <= to_datetime)
# Apply sorting
if request.sort_column and request.sort_direction:
sort_direction = request.sort_direction.lower()
if sort_direction not in ['asc', 'desc']:
raise HTTPException(status_code=400, detail="Invalid sort direction")
sort_mapping = {
'memory': Memory.content,
'app_name': App.name,
'created_at': Memory.created_at
}
if request.sort_column not in sort_mapping:
raise HTTPException(status_code=400, detail="Invalid sort column")
sort_field = sort_mapping[request.sort_column]
if sort_direction == 'desc':
query = query.order_by(sort_field.desc())
else:
query = query.order_by(sort_field.asc())
else:
# Default sorting
query = query.order_by(Memory.created_at.desc())
# Add eager loading for categories and make the query distinct
query = query.options(
joinedload(Memory.categories)
).distinct(Memory.id)
# Use fastapi-pagination's paginate function
return sqlalchemy_paginate(
query,
Params(page=request.page, size=request.size),
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
]
)
@router.get("/{memory_id}/related", response_model=Page[MemoryResponse])
async def get_related_memories(
memory_id: UUID,
user_id: str,
params: Params = Depends(),
db: Session = Depends(get_db)
):
# Validate user
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get the source memory
memory = get_memory_or_404(db, memory_id)
# Extract category IDs from the source memory
category_ids = [category.id for category in memory.categories]
if not category_ids:
return Page.create([], total=0, params=params)
# Build query for related memories
query = db.query(Memory).distinct(Memory.id).filter(
Memory.user_id == user.id,
Memory.id != memory_id,
Memory.state != MemoryState.deleted
).join(Memory.categories).filter(
Category.id.in_(category_ids)
).options(
joinedload(Memory.categories),
joinedload(Memory.app)
).order_by(
func.count(Category.id).desc(),
Memory.created_at.desc()
).group_by(Memory.id)
# ⚡ Force page size to be 5
params = Params(page=params.page, size=5)
return sqlalchemy_paginate(
query,
params,
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
]
)

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Memory, App, MemoryState
router = APIRouter(prefix="/api/v1/stats", tags=["stats"])
@router.get("/")
async def get_profile(
user_id: str,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get total number of memories
total_memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted).count()
# Get total number of apps
apps = db.query(App).filter(App.owner == user)
total_apps = apps.count()
return {
"total_memories": total_memories,
"total_apps": total_apps,
"apps": apps.all()
}

View File

@@ -0,0 +1,64 @@
from datetime import datetime
from typing import Optional, List
from uuid import UUID
from pydantic import BaseModel, Field, validator
class MemoryBase(BaseModel):
content: str
metadata_: Optional[dict] = Field(default_factory=dict)
class MemoryCreate(MemoryBase):
user_id: UUID
app_id: UUID
class Category(BaseModel):
name: str
class App(BaseModel):
id: UUID
name: str
class Memory(MemoryBase):
id: UUID
user_id: UUID
app_id: UUID
created_at: datetime
updated_at: Optional[datetime] = None
state: str
categories: Optional[List[Category]] = None
app: App
class Config:
from_attributes = True
class MemoryUpdate(BaseModel):
content: Optional[str] = None
metadata_: Optional[dict] = None
state: Optional[str] = None
class MemoryResponse(BaseModel):
id: UUID
content: str
created_at: int
state: str
app_id: UUID
app_name: str
categories: List[str]
metadata_: Optional[dict] = None
@validator('created_at', pre=True)
def convert_to_epoch(cls, v):
if isinstance(v, datetime):
return int(v.timestamp())
return v
class PaginatedMemoryResponse(BaseModel):
items: List[MemoryResponse]
total: int
page: int
size: int
pages: int

View File

View File

@@ -0,0 +1,37 @@
import json
import logging
from openai import OpenAI
from typing import List
from dotenv import load_dotenv
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential
from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT
load_dotenv()
openai_client = OpenAI()
class MemoryCategories(BaseModel):
categories: List[str]
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
def get_categories_for_memory(memory: str) -> List[str]:
"""Get categories for a memory."""
try:
response = openai_client.responses.parse(
model="gpt-4o-mini",
instructions=MEMORY_CATEGORIZATION_PROMPT,
input=memory,
temperature=0,
text_format=MemoryCategories,
)
response_json =json.loads(response.output[0].content[0].text)
categories = response_json['categories']
categories = [cat.strip().lower() for cat in categories]
# TODO: Validate categories later may be
return categories
except Exception as e:
raise e

View File

@@ -0,0 +1,32 @@
from sqlalchemy.orm import Session
from app.models import User, App
from typing import Tuple
def get_or_create_user(db: Session, user_id: str) -> User:
"""Get or create a user with the given user_id"""
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
user = User(user_id=user_id)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_or_create_app(db: Session, user: User, app_id: str) -> App:
"""Get or create an app for the given user"""
app = db.query(App).filter(App.owner_id == user.id, App.name == app_id).first()
if not app:
app = App(owner_id=user.id, name=app_id)
db.add(app)
db.commit()
db.refresh(app)
return app
def get_user_and_app(db: Session, user_id: str, app_id: str) -> Tuple[User, App]:
"""Get or create both user and their app"""
user = get_or_create_user(db, user_id)
app = get_or_create_app(db, user, app_id)
return user, app

View File

@@ -0,0 +1,51 @@
import os
from mem0 import Memory
memory_client = None
def get_memory_client(custom_instructions: str = None):
"""
Get or initialize the Mem0 client.
Args:
custom_instructions: Optional instructions for the memory project.
Returns:
Initialized Mem0 client instance.
Raises:
Exception: If required API keys are not set.
"""
global memory_client
if memory_client is not None:
return memory_client
try:
config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "openmemory",
"host": "mem0_store",
"port": 6333,
}
}
}
memory_client = Memory.from_config(config_dict=config)
except Exception:
raise Exception("Exception occurred while initializing memory client")
# Update project with custom instructions if provided
if custom_instructions:
memory_client.update_project(custom_instructions=custom_instructions)
return memory_client
def get_default_user_id():
return "default_user"

View File

@@ -0,0 +1,52 @@
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models import Memory, App, MemoryState
def check_memory_access_permissions(
db: Session,
memory: Memory,
app_id: Optional[UUID] = None
) -> bool:
"""
Check if the given app has permission to access a memory based on:
1. Memory state (must be active)
2. App state (must not be paused)
3. App-specific access controls
Args:
db: Database session
memory: Memory object to check access for
app_id: Optional app ID to check permissions for
Returns:
bool: True if access is allowed, False otherwise
"""
# Check if memory is active
if memory.state != MemoryState.active:
return False
# If no app_id provided, only check memory state
if not app_id:
return True
# Check if app exists and is active
app = db.query(App).filter(App.id == app_id).first()
if not app:
return False
# Check if app is paused/inactive
if not app.is_active:
return False
# Check app-specific access controls
from app.routers.memories import get_accessible_memory_ids
accessible_memory_ids = get_accessible_memory_ids(db, app_id)
# If accessible_memory_ids is None, all memories are accessible
if accessible_memory_ids is None:
return True
# Check if memory is in the accessible set
return memory.id in accessible_memory_ids

View File

@@ -0,0 +1,28 @@
MEMORY_CATEGORIZATION_PROMPT = """Your task is to assign each piece of information (or “memory”) to one or more of the following categories. Feel free to use multiple categories per item when appropriate.
- Personal: family, friends, home, hobbies, lifestyle
- Relationships: social network, significant others, colleagues
- Preferences: likes, dislikes, habits, favorite media
- Health: physical fitness, mental health, diet, sleep
- Travel: trips, commutes, favorite places, itineraries
- Work: job roles, companies, projects, promotions
- Education: courses, degrees, certifications, skills development
- Projects: todos, milestones, deadlines, status updates
- AI, ML & Technology: infrastructure, algorithms, tools, research
- Technical Support: bug reports, error logs, fixes
- Finance: income, expenses, investments, billing
- Shopping: purchases, wishlists, returns, deliveries
- Legal: contracts, policies, regulations, privacy
- Entertainment: movies, music, games, books, events
- Messages: emails, SMS, alerts, reminders
- Customer Support: tickets, inquiries, resolutions
- Product Feedback: ratings, bug reports, feature requests
- News: articles, headlines, trending topics
- Organization: meetings, appointments, calendars
- Goals: ambitions, KPIs, longterm objectives
Guidelines:
- Return only the categories under 'categories' key in the JSON format.
- If you cannot categorize the memory, return an empty list with key 'categories'.
- Don't limit yourself to the categories listed above only. Feel free to create new categories based on the memory. Make sure that it is a single phrase.
"""

View File

@@ -0,0 +1,26 @@
services:
mem0_store:
image: qdrant/qdrant
ports:
- "6333:6333"
volumes:
- mem0_storage:/mem0/storage
api:
image: mem0/openmemory-mcp
build: .
environment:
- OPENAI_API_KEY
- USER
env_file:
- .env
depends_on:
- mem0_store
ports:
- "8765:8765"
volumes:
- .:/usr/src/openmemory
command: >
sh -c "uvicorn main:app --host 0.0.0.0 --port 8765 --reload --workers 4"
volumes:
mem0_storage:

86
openmemory/api/main.py Normal file
View File

@@ -0,0 +1,86 @@
import datetime
from fastapi import FastAPI
from app.database import engine, Base, SessionLocal
from app.mcp_server import setup_mcp_server
from app.routers import memories_router, apps_router, stats_router
from fastapi_pagination import add_pagination
from fastapi.middleware.cors import CORSMiddleware
from app.models import User, App
from uuid import uuid4
from app.config import USER_ID, DEFAULT_APP_ID
app = FastAPI(title="OpenMemory API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Create all tables
Base.metadata.create_all(bind=engine)
# Check for USER_ID and create default user if needed
def create_default_user():
db = SessionLocal()
try:
# Check if user exists
user = db.query(User).filter(User.user_id == USER_ID).first()
if not user:
# Create default user
user = User(
id=uuid4(),
user_id=USER_ID,
name="Default User",
created_at=datetime.datetime.now(datetime.UTC)
)
db.add(user)
db.commit()
finally:
db.close()
def create_default_app():
db = SessionLocal()
try:
user = db.query(User).filter(User.user_id == USER_ID).first()
if not user:
return
# Check if app already exists
existing_app = db.query(App).filter(
App.name == DEFAULT_APP_ID,
App.owner_id == user.id
).first()
if existing_app:
return
app = App(
id=uuid4(),
name=DEFAULT_APP_ID,
owner_id=user.id,
created_at=datetime.datetime.now(datetime.UTC),
updated_at=datetime.datetime.now(datetime.UTC),
)
db.add(app)
db.commit()
finally:
db.close()
# Create default user on startup
create_default_user()
create_default_app()
# Setup MCP server
setup_mcp_server(app)
# Include routers
app.include_router(memories_router)
app.include_router(apps_router)
app.include_router(stats_router)
# Add pagination support
add_pagination(app)

View File

@@ -0,0 +1,15 @@
fastapi>=0.68.0
uvicorn>=0.15.0
sqlalchemy>=1.4.0
python-dotenv>=0.19.0
alembic>=1.7.0
psycopg2-binary>=2.9.0
python-multipart>=0.0.5
fastapi-pagination>=0.12.0
mem0ai>=0.1.92
mcp[cli]>=1.3.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.24.0
pytest-cov>=4.0.0
tenacity==9.1.2

2
openmemory/ui/.env.dev Normal file
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID

View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_USER_ID=default-user

62
openmemory/ui/Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
# syntax=docker.io/docker/dockerfile:1
# Base stage for common setup
FROM node:18-alpine AS base
# Install dependencies for pnpm
RUN apk add --no-cache libc6-compat curl && \
corepack enable && \
corepack prepare pnpm@latest --activate
WORKDIR /app
# Dependencies stage
FROM base AS deps
# Copy lockfile and manifest
COPY package.json pnpm-lock.yaml ./
# Install dependencies using pnpm
RUN pnpm install --frozen-lockfile
# Builder stage
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY . .
RUN cp next.config.dev.mjs next.config.mjs
RUN cp .env.dev .env
RUN pnpm build
# Production runner stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy production dependencies and built artifacts
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy and prepare entrypoint script
COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh
RUN chmod +x /home/nextjs/entrypoint.sh
# Switch to non-root user
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/home/nextjs/entrypoint.sh"]
CMD ["node", "server.js"]

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,146 @@
"use client";
import { Button } from "@/components/ui/button";
import { HiHome, HiMiniRectangleStack } from "react-icons/hi2";
import { RiApps2AddFill } from "react-icons/ri";
import { FiRefreshCcw } from "react-icons/fi";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CreateMemoryDialog } from "@/app/memories/components/CreateMemoryDialog";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import Image from "next/image";
import { useStats } from "@/hooks/useStats";
import { useAppsApi } from "@/hooks/useAppsApi";
export function Navbar() {
const pathname = usePathname();
const memoriesApi = useMemoriesApi();
const appsApi = useAppsApi();
const statsApi = useStats();
// Define route matchers with typed parameter extraction
const routeBasedFetchMapping: {
match: RegExp;
getFetchers: (params: Record<string, string>) => (() => Promise<any>)[];
}[] = [
{
match: /^\/memory\/([^/]+)$/,
getFetchers: ({ memory_id }) => [
() => memoriesApi.fetchMemoryById(memory_id),
() => memoriesApi.fetchAccessLogs(memory_id),
() => memoriesApi.fetchRelatedMemories(memory_id),
],
},
{
match: /^\/apps\/([^/]+)$/,
getFetchers: ({ app_id }) => [
() => appsApi.fetchAppMemories(app_id),
() => appsApi.fetchAppAccessedMemories(app_id),
() => appsApi.fetchAppDetails(app_id),
],
},
{
match: /^\/memories$/,
getFetchers: () => [memoriesApi.fetchMemories],
},
{
match: /^\/apps$/,
getFetchers: () => [appsApi.fetchApps],
},
{
match: /^\/$/,
getFetchers: () => [statsApi.fetchStats, memoriesApi.fetchMemories],
},
];
const getFetchersForPath = (path: string) => {
for (const route of routeBasedFetchMapping) {
const match = path.match(route.match);
if (match) {
if (route.match.source.includes("memory")) {
return route.getFetchers({ memory_id: match[1] });
}
if (route.match.source.includes("app")) {
return route.getFetchers({ app_id: match[1] });
}
return route.getFetchers({});
}
}
return [];
};
const handleRefresh = async () => {
const fetchers = getFetchersForPath(pathname);
await Promise.allSettled(fetchers.map((fn) => fn()));
};
const isActive = (href: string) => {
if (href === "/") return pathname === href;
return pathname.startsWith(href.substring(0, 5));
};
const activeClass = "bg-zinc-800 text-white border-zinc-600";
const inactiveClass = "text-zinc-300";
return (
<header className="sticky top-0 z-50 w-full border-b border-zinc-800 bg-zinc-950/95 backdrop-blur supports-[backdrop-filter]:bg-zinc-950/60">
<div className="container flex h-14 items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Image src="/logo.svg" alt="OpenMemory" width={26} height={26} />
<span className="text-xl font-medium">OpenMemory</span>
</Link>
<div className="flex items-center gap-2">
<Link href="/">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-2 border-none ${
isActive("/") ? activeClass : inactiveClass
}`}
>
<HiHome />
Dashboard
</Button>
</Link>
<Link href="/memories">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-2 border-none ${
isActive("/memories") ? activeClass : inactiveClass
}`}
>
<HiMiniRectangleStack />
Memories
</Button>
</Link>
<Link href="/apps">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-2 border-none ${
isActive("/apps") ? activeClass : inactiveClass
}`}
>
<RiApps2AddFill />
Apps
</Button>
</Link>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
>
<FiRefreshCcw className="transition-transform duration-300 group-hover:rotate-180" />
Refresh
</Button>
<CreateMemoryDialog />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Copy, Check } from "lucide-react";
import Image from "next/image";
const clientTabs = [
{ key: "claude", label: "Claude", icon: "/images/claude.webp" },
{ key: "cursor", label: "Cursor", icon: "/images/cursor.png" },
{ key: "cline", label: "Cline", icon: "/images/cline.png" },
{ key: "roocline", label: "Roo Cline", icon: "/images/roocline.png" },
{ key: "windsurf", label: "Windsurf", icon: "/images/windsurf.png" },
{ key: "witsy", label: "Witsy", icon: "/images/witsy.png" },
{ key: "enconvo", label: "Enconvo", icon: "/images/enconvo.png" },
];
const colorGradientMap: { [key: string]: string } = {
claude:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]",
cline:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(112,128,144,0.3),_rgba(112,128,144,0))] data-[state=active]:border-[#708090]",
cursor:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(255,255,255,0.08),_rgba(255,255,255,0))] data-[state=active]:border-[#708090]",
roocline:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(45,32,92,0.8),_rgba(45,32,92,0))] data-[state=active]:border-[#7E3FF2]",
windsurf:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(0,176,137,0.3),_rgba(0,176,137,0))] data-[state=active]:border-[#00B089]",
witsy:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(33,135,255,0.3),_rgba(33,135,255,0))] data-[state=active]:border-[#2187FF]",
enconvo:
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]",
};
const getColorGradient = (color: string) => {
if (colorGradientMap[color]) {
return colorGradientMap[color];
}
return "data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]";
};
const allTabs = [{ key: "mcp", label: "MCP Link", icon: "🔗" }, ...clientTabs];
export const Install = () => {
const [copiedTab, setCopiedTab] = useState<string | null>(null);
const user = process.env.NEXT_PUBLIC_USER_ID || "user";
const URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const handleCopy = async (tab: string, isMcp: boolean = false) => {
const text = isMcp
? `${URL}/mcp/openmemory/sse/${user}`
: `npx install-mcp i ${URL}/mcp/${tab}/sse/${user} --client ${tab}`;
try {
// Try using the Clipboard API first
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback: Create a temporary textarea element
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
// Update UI to show success
setCopiedTab(tab);
setTimeout(() => setCopiedTab(null), 1500); // Reset after 1.5s
} catch (error) {
console.error("Failed to copy text:", error);
// You might want to add a toast notification here to show the error
}
};
return (
<div>
<h2 className="text-xl font-semibold mb-6">Install OpenMemory</h2>
<div className="hidden">
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(112,128,144,0.3),_rgba(112,128,144,0))] data-[state=active]:border-[#708090]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(45,32,92,0.3),_rgba(45,32,92,0))] data-[state=active]:border-[#2D205C]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(0,176,137,0.3),_rgba(0,176,137,0))] data-[state=active]:border-[#00B089]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(33,135,255,0.3),_rgba(33,135,255,0))] data-[state=active]:border-[#2187FF]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(107,33,168,0.3),_rgba(107,33,168,0))] data-[state=active]:border-primary"></div>
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(255,255,255,0.08),_rgba(255,255,255,0))] data-[state=active]:border-[#708090]"></div>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="bg-transparent border-b border-zinc-800 rounded-none w-full justify-start gap-0 p-0 grid grid-cols-8">
{allTabs.map(({ key, label, icon }) => (
<TabsTrigger
key={key}
value={key}
className={`flex-1 px-0 pb-2 rounded-none ${getColorGradient(
key
)} data-[state=active]:border-b-2 data-[state=active]:shadow-none text-zinc-400 data-[state=active]:text-white flex items-center justify-center gap-2 text-sm`}
>
{icon.startsWith("/") ? (
<div>
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image src={icon} alt={label} width={40} height={40} />
</div>
</div>
) : (
<div className="h-6">
<span className="relative top-1">{icon}</span>
</div>
)}
<span>{label}</span>
</TabsTrigger>
))}
</TabsList>
{/* MCP Tab Content */}
<TabsContent value="mcp" className="mt-6">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="py-4">
<CardTitle className="text-white text-xl">MCP Link</CardTitle>
</CardHeader>
<hr className="border-zinc-800" />
<CardContent className="py-4">
<div className="relative">
<pre className="bg-zinc-800 px-4 py-3 rounded-md overflow-x-auto text-sm">
<code className="text-gray-300">
{URL}/mcp/openmemory/sse/{user}
</code>
</pre>
<div>
<button
className="absolute top-0 right-0 py-3 px-4 rounded-md hover:bg-zinc-600 bg-zinc-700"
aria-label="Copy to clipboard"
onClick={() => handleCopy("mcp", true)}
>
{copiedTab === "mcp" ? (
<Check className="h-5 w-5 text-green-400" />
) : (
<Copy className="h-5 w-5 text-zinc-400" />
)}
</button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Client Tabs Content */}
{clientTabs.map(({ key }) => (
<TabsContent key={key} value={key} className="mt-6">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="py-4">
<CardTitle className="text-white text-xl">
{key.charAt(0).toUpperCase() + key.slice(1)} Installation
Command
</CardTitle>
</CardHeader>
<hr className="border-zinc-800" />
<CardContent className="py-4">
<div className="relative">
<pre className="bg-zinc-800 px-4 py-3 rounded-md overflow-x-auto text-sm">
<code className="text-gray-300">
{`npx install-mcp i ${URL}/mcp/${key}/sse/${user} --client ${key}`}
</code>
</pre>
<div>
<button
className="absolute top-0 right-0 py-3 px-4 rounded-md hover:bg-zinc-600 bg-zinc-700"
aria-label="Copy to clipboard"
onClick={() => handleCopy(key)}
>
{copiedTab === key ? (
<Check className="h-5 w-5 text-green-400" />
) : (
<Copy className="h-5 w-5 text-zinc-400" />
)}
</button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
);
};
export default Install;

View File

@@ -0,0 +1,69 @@
import React, { useEffect } from "react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useStats } from "@/hooks/useStats";
import Image from "next/image";
import { constants } from "@/components/shared/source-app";
const Stats = () => {
const totalMemories = useSelector(
(state: RootState) => state.profile.totalMemories
);
const totalApps = useSelector((state: RootState) => state.profile.totalApps);
const apps = useSelector((state: RootState) => state.profile.apps).slice(
0,
4
);
const { fetchStats } = useStats();
useEffect(() => {
fetchStats();
}, []);
return (
<div className="bg-zinc-900 rounded-lg border border-zinc-800">
<div className="bg-zinc-800 border-b border-zinc-800 rounded-t-lg p-4">
<div className="text-white text-xl font-semibold">Memories Stats</div>
</div>
<div className="space-y-3 p-4">
<div>
<p className="text-zinc-400">Total Memories</p>
<h3 className="text-lg font-bold text-white">
{totalMemories} Memories
</h3>
</div>
<div>
<p className="text-zinc-400">Total Apps Connected</p>
<div className="flex flex-col items-start gap-1 mt-2">
<div className="flex -space-x-2">
{apps.map((app) => (
<div
key={app.id}
className={`h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs`}
>
<div>
<div className="w-7 h-7 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
<Image
src={
constants[app.name as keyof typeof constants]
?.iconImage || ""
}
alt={
constants[app.name as keyof typeof constants]?.name
}
width={32}
height={32}
/>
</div>
</div>
</div>
))}
</div>
<h3 className="text-lg font-bold text-white">{totalApps} Apps</h3>
</div>
</div>
</div>
</div>
);
};
export default Stats;

View File

@@ -0,0 +1,230 @@
import React, { useState } from "react";
import {
Book,
HeartPulse,
BriefcaseBusiness,
CircleHelp,
Palette,
Code,
Settings,
Users,
Heart,
Brain,
MapPin,
Globe,
PersonStandingIcon,
} from "lucide-react";
import {
FaLaptopCode,
FaPaintBrush,
FaBusinessTime,
FaRegHeart,
FaRegSmile,
FaUserTie,
FaMoneyBillWave,
FaBriefcase,
FaPlaneDeparture,
} from "react-icons/fa";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "../ui/badge";
type Category = string;
const defaultIcon = <CircleHelp className="w-4 h-4 mr-2" />;
const iconMap: Record<string, any> = {
// Core themes
health: <HeartPulse className="w-4 h-4 mr-2" />,
wellness: <Heart className="w-4 h-4 mr-2" />,
fitness: <HeartPulse className="w-4 h-4 mr-2" />,
education: <Book className="w-4 h-4 mr-2" />,
learning: <Book className="w-4 h-4 mr-2" />,
school: <Book className="w-4 h-4 mr-2" />,
coding: <FaLaptopCode className="w-4 h-4 mr-2" />,
programming: <Code className="w-4 h-4 mr-2" />,
development: <Code className="w-4 h-4 mr-2" />,
tech: <Settings className="w-4 h-4 mr-2" />,
design: <FaPaintBrush className="w-4 h-4 mr-2" />,
art: <Palette className="w-4 h-4 mr-2" />,
creativity: <Palette className="w-4 h-4 mr-2" />,
psychology: <Brain className="w-4 h-4 mr-2" />,
mental: <Brain className="w-4 h-4 mr-2" />,
social: <Users className="w-4 h-4 mr-2" />,
peronsal: <PersonStandingIcon className="w-4 h-4 mr-2" />,
life: <Heart className="w-4 h-4 mr-2" />,
// Work / Career
business: <FaBusinessTime className="w-4 h-4 mr-2" />,
work: <FaBriefcase className="w-4 h-4 mr-2" />,
career: <FaUserTie className="w-4 h-4 mr-2" />,
jobs: <BriefcaseBusiness className="w-4 h-4 mr-2" />,
finance: <FaMoneyBillWave className="w-4 h-4 mr-2" />,
money: <FaMoneyBillWave className="w-4 h-4 mr-2" />,
// Preferences
preference: <FaRegHeart className="w-4 h-4 mr-2" />,
interest: <FaRegSmile className="w-4 h-4 mr-2" />,
// Travel & Location
travel: <FaPlaneDeparture className="w-4 h-4 mr-2" />,
journey: <FaPlaneDeparture className="w-4 h-4 mr-2" />,
location: <MapPin className="w-4 h-4 mr-2" />,
trip: <Globe className="w-4 h-4 mr-2" />,
places: <Globe className="w-4 h-4 mr-2" />,
};
const getClosestIcon = (label: string): any => {
const normalized = label.toLowerCase().split(/[\s\-_.]+/);
let bestMatch: string | null = null;
let bestScore = 0;
Object.keys(iconMap).forEach((key) => {
const keyTokens = key.split(/[\s\-_.]+/);
const matchScore = normalized.filter((word) =>
keyTokens.some((token) => word.includes(token) || token.includes(word))
).length;
if (matchScore > bestScore) {
bestScore = matchScore;
bestMatch = key;
}
});
return bestMatch ? iconMap[bestMatch] : defaultIcon;
};
const getColor = (label: string): string => {
const l = label.toLowerCase();
if (l.includes("health") || l.includes("fitness"))
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
if (l.includes("education") || l.includes("school"))
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/20";
if (
l.includes("business") ||
l.includes("career") ||
l.includes("work") ||
l.includes("finance")
)
return "text-amber-400 bg-amber-500/10 border-amber-500/20";
if (l.includes("design") || l.includes("art") || l.includes("creative"))
return "text-pink-400 bg-pink-500/10 border-pink-500/20";
if (l.includes("tech") || l.includes("code") || l.includes("programming"))
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
if (l.includes("interest") || l.includes("preference"))
return "text-rose-400 bg-rose-500/10 border-rose-500/20";
if (
l.includes("travel") ||
l.includes("trip") ||
l.includes("location") ||
l.includes("place")
)
return "text-sky-400 bg-sky-500/10 border-sky-500/20";
if (l.includes("personal") || l.includes("life"))
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
};
const Categories = ({
categories,
isPaused = false,
concat = false,
}: {
categories: Category[];
isPaused?: boolean;
concat?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
if (!categories || categories.length === 0) return null;
const baseBadgeStyle =
"backdrop-blur-sm transition-colors hover:bg-opacity-20";
const pausedStyle =
"text-zinc-500 bg-zinc-800/40 border-zinc-700/40 hover:bg-zinc-800/60";
if (concat) {
const remainingCount = categories.length - 1;
return (
<div className="flex flex-wrap gap-2">
{/* First category */}
<Badge
variant="outline"
className={`${
isPaused
? pausedStyle
: `${getColor(categories[0])} ${baseBadgeStyle}`
}`}
>
{categories[0]}
</Badge>
{/* Popover for remaining categories */}
{remainingCount > 0 && (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<Badge
variant="outline"
className={
isPaused
? pausedStyle
: "text-zinc-400 bg-zinc-500/10 border-zinc-500/20 hover:bg-zinc-500/20"
}
>
+{remainingCount}
</Badge>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2 border bg-[#27272A] border-zinc-700/60 rounded-2xl"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<div className="flex flex-col gap-2">
{categories.slice(1).map((cat, i) => (
<Badge
key={i}
variant="outline"
className={`${
isPaused
? pausedStyle
: `${getColor(cat)} ${baseBadgeStyle}`
}`}
>
{cat}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}
// Default view
return (
<div className="flex flex-wrap gap-2">
{categories?.map((cat, i) => (
<Badge
key={i}
variant="outline"
className={`${
isPaused ? pausedStyle : `${getColor(cat)} ${baseBadgeStyle}`
}`}
>
{cat}
</Badge>
))}
</div>
);
};
export default Categories;

View File

@@ -0,0 +1,80 @@
import React from "react";
import { BiEdit } from "react-icons/bi";
import Image from "next/image";
export const Icon = ({ source }: { source: string }) => {
return (
<div className="w-4 h-4 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden -mr-1">
<Image src={source} alt={source} width={40} height={40} />
</div>
);
};
export const constants = {
claude: {
name: "Claude",
icon: <Icon source="/images/claude.webp" />,
iconImage: "/images/claude.webp",
},
openmemory: {
name: "OpenMemory",
icon: <Icon source="/images/open-memory.svg" />,
iconImage: "/images/open-memory.svg",
},
cursor: {
name: "Cursor",
icon: <Icon source="/images/cursor.png" />,
iconImage: "/images/cursor.png",
},
cline: {
name: "Cline",
icon: <Icon source="/images/cline.png" />,
iconImage: "/images/cline.png",
},
roocline: {
name: "Roo Cline",
icon: <Icon source="/images/roocline.png" />,
iconImage: "/images/roocline.png",
},
windsurf: {
name: "Windsurf",
icon: <Icon source="/images/windsurf.png" />,
iconImage: "/images/windsurf.png",
},
witsy: {
name: "Witsy",
icon: <Icon source="/images/witsy.png" />,
iconImage: "/images/witsy.png",
},
enconvo: {
name: "Enconvo",
icon: <Icon source="/images/enconvo.png" />,
iconImage: "/images/enconvo.png",
},
default: {
name: "Default",
icon: <BiEdit size={18} className="ml-1" />,
iconImage: "/images/default.png",
},
};
const SourceApp = ({ source }: { source: string }) => {
if (!constants[source as keyof typeof constants]) {
return (
<div>
<BiEdit />
<span className="text-sm font-semibold">{source}</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
{constants[source as keyof typeof constants].icon}
<span className="text-sm font-semibold">
{constants[source as keyof typeof constants].name}
</span>
</div>
);
};
export default SourceApp;

View File

@@ -0,0 +1,93 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useRef } from "react";
import { Loader2 } from "lucide-react";
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { usePathname } from "next/navigation";
interface UpdateMemoryProps {
memoryId: string;
memoryContent: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const UpdateMemory = ({
memoryId,
memoryContent,
open,
onOpenChange,
}: UpdateMemoryProps) => {
const { updateMemory, isLoading, fetchMemories, fetchMemoryById } =
useMemoriesApi();
const textRef = useRef<HTMLTextAreaElement>(null);
const pathname = usePathname();
const handleUpdateMemory = async (text: string) => {
try {
await updateMemory(memoryId, text);
toast.success("Memory updated successfully");
onOpenChange(false);
if (pathname.includes("memories")) {
await fetchMemories();
} else {
await fetchMemoryById(memoryId);
}
} catch (error) {
console.error(error);
toast.error("Failed to update memory");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px] bg-zinc-900 border-zinc-800 z-50">
<DialogHeader>
<DialogTitle>Update Memory</DialogTitle>
<DialogDescription>Edit your existing memory</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="memory">Memory</Label>
<Textarea
ref={textRef}
id="memory"
className="bg-zinc-950 border-zinc-800 min-h-[150px]"
defaultValue={memoryContent}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="w-[140px]"
disabled={isLoading}
onClick={() => handleUpdateMemory(textRef?.current?.value || "")}
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
"Update Memory"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default UpdateMemory;

View File

@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,13 @@
export type Category = "personal" | "work" | "health" | "finance" | "travel" | "education" | "preferences" | "relationships"
export type Client = "chrome" | "chatgpt" | "cursor" | "windsurf" | "terminal" | "api"
export interface Memory {
id: string
memory: string
metadata: any
client: Client
categories: Category[]
created_at: number
app_name: string
state: "active" | "paused" | "archived" | "deleted"
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,236 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

Some files were not shown because too many files have changed in this diff Show More