Gemini revision: Next.js kites implementation

This commit is contained in:
fullsizemalt 2025-11-20 18:49:32 -08:00
parent 5dcb86bcd5
commit 292d7adedc
67 changed files with 10263 additions and 398 deletions

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

92
README.md Normal file
View file

@ -0,0 +1,92 @@
# Kites - Agentic Pastebin
Kites is a clean, agent-native pastebin service designed for LLMs and humans. It features a simple API, structured data model (Sessions, Tags), and a polished UI.
## Features
- **Agent-First API**: Simple REST endpoints for creating and retrieving pastes.
- **Sessions**: Group pastes by "run" or logical session.
- **Tags**: Organize pastes with tags.
- **Syntax Highlighting**: Automatic highlighting for various languages.
- **Theming**: Light and Dark mode support.
- **Self-Hostable**: Built with Next.js and SQLite for easy deployment.
## Tech Stack
- **Framework**: Next.js 15 (App Router)
- **Database**: SQLite
- **ORM**: Drizzle ORM
- **Styling**: Tailwind CSS v4
- **Validation**: Zod
## Getting Started
### Prerequisites
- Node.js 18+
- npm
### Installation
1. Clone the repository:
```bash
git clone https://github.com/fullsizemalt/kites.git
cd kites
```
2. Install dependencies:
```bash
npm install
```
3. Initialize the database:
```bash
npx drizzle-kit push
```
4. Run the development server:
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser.
## API Usage
### Create a Paste
```bash
curl -X POST http://localhost:3000/api/v1/pastes \
-H "Content-Type: application/json" \
-d '{
"content": "console.log(\"Hello Kites!\");",
"title": "My First Paste",
"syntax": "javascript",
"tags": ["demo", "js"]
}'
```
### Get a Paste (Raw)
```bash
curl http://localhost:3000/api/v1/pastes/<PASTE_ID>?raw=1
```
### Create a Session
```bash
curl -X POST http://localhost:3000/api/v1/sessions \
-H "Content-Type: application/json" \
-d '{
"title": "Agent Run 101",
"agentName": "GPT-4"
}'
```
## Agent Example
See `examples/agent_usage.py` for a Python script that demonstrates how an agent can interact with Kites.
```bash
python3 examples/agent_usage.py
```

10
drizzle.config.ts Normal file
View file

@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: 'sqlite.db',
},
} satisfies Config;

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

71
examples/agent_usage.py Normal file
View file

@ -0,0 +1,71 @@
import requests
import json
import time
BASE_URL = "http://localhost:3000/api/v1"
def create_session(title, agent_name):
print(f"Creating session: {title}...")
response = requests.post(f"{BASE_URL}/sessions", json={
"title": title,
"agentName": agent_name
})
response.raise_for_status()
data = response.json()
print(f"Session created: {data['id']}")
return data['id']
def create_paste(content, title=None, session_id=None, tags=None, syntax=None):
print(f"Creating paste: {title}...")
payload = {
"content": content,
"title": title,
"sessionId": session_id,
"tags": tags or [],
"syntax": syntax,
"visibility": "public"
}
response = requests.post(f"{BASE_URL}/pastes", json=payload)
response.raise_for_status()
data = response.json()
print(f"Paste created: {data['id']} (URL: http://localhost:3000/paste/{data['id']})")
return data['id']
def get_session_pastes(session_id):
print(f"Fetching pastes for session {session_id}...")
response = requests.get(f"{BASE_URL}/sessions/{session_id}/pastes")
response.raise_for_status()
pastes = response.json()
print(f"Found {len(pastes)} pastes.")
for p in pastes:
print(f" - {p['title']}: {p['id']}")
def main():
# 1. Create a session
session_id = create_session("Refactor Run #1", "Antigravity Agent")
# 2. Create some pastes
create_paste(
content="print('Hello World')",
title="Hello World Python",
session_id=session_id,
tags=["python", "example"],
syntax="python"
)
create_paste(
content="SELECT * FROM users;",
title="User Query",
session_id=session_id,
tags=["sql", "db"],
syntax="sql"
)
# 3. List pastes in session
get_session_pastes(session_id)
if __name__ == "__main__":
try:
main()
except requests.exceptions.ConnectionError:
print("Error: Could not connect to Kites API. Make sure the server is running on http://localhost:3000")

209
kites.py
View file

@ -1,209 +0,0 @@
import os
# Root project directory
root_dir = "kites"
# Major project subfolders
subfolders = [
"api",
"worker-jobs",
"web-ui",
"overlay-service",
"browser-extension",
"clipboard-daemon",
"storage",
"sdk",
"docs",
"tests",
"scripts",
"config",
"metrics",
"security",
"spec-kit/spec",
"spec-kit/plan",
"spec-kit/tasks"
]
# Create directories
for folder in subfolders:
os.makedirs(os.path.join(root_dir, folder), exist_ok=True)
# Spec Kit markdown contents
feature_spec_md = """# Feature Spec
## Overview
Kites is a secure, structured snippet service for curated text, code, and images. It supports AI agent CLIs, developers, teams, self-hosters, and streamers. The system prioritizes reliable snippet capture, privacy-preserving self-hosting, fast retrieval, CLI-ready formatting, and streamer-friendly overlays.
## Goals
- Enable fast, reliable snippet capture with p95 write latency under 400ms.
- Ensure full secret redaction with 100% test coverage.
- Enable session import/export with no data loss.
- Provide overlays for streamers with real-time updates.
- Allow AI agents full programmatic access for autonomous operation.
- Operate self-hosted with strong privacy and security.
## User Stories
- As an AI agent CLI, I want to programmatically create, read, update, and delete snippets with context so I can automate snippet handling.
- As a developer, I want fast read/write APIs with low latency and raw formatted snippet retrieval for seamless CLI integration.
- As a streamer, I want live snippet overlays in OBS with low latency for dynamic display on streams.
- As a self-hosting team, I want privacy controls including secret redaction, zero-knowledge mode, and audit logging.
- As an end-user, I want a browser extension and clipboard daemon to capture snippets with metadata and enforce security policies.
## Acceptance Criteria
- Snippet CRUD with tags, categories, and context expansion works with latency goals met.
- Raw snippet retrieval excludes line numbers and matches displayed content exactly.
- Secret redaction masks all seeded secrets in test corpus.
- Session URLs expire and revoked sessions return HTTP 410.
- Overlay updates propagate to OBS with 150ms p95 latency.
- Clipboard daemon blocks sensitive data automatically.
- Browser extension captures page selections with provenance metadata.
"""
technical_plan_md = """# Technical Plan
## Architecture & Stack
- API: Fastify (Node.js) with OpenAPI v3 spec, alternative Go backend planned.
- Worker jobs: asynchronous redaction, summaries, screenshot sanitation.
- Web UI: Next.js for management and monitoring.
- Overlay service: supports streaming software (OBS).
- Browser extension: MV3, captures page selections.
- Clipboard daemon: Electron/Tauri app for system clipboard capture and rule enforcement.
- Storage: PostgreSQL (prod), SQLite (dev), Redis for job management, S3/MinIO for binaries/screenshots.
## Key Models
- Session: Aggregates snippets, context windows, metadata.
- Snippet: Individual captured content with tags and categories.
- Overlay: Configuration for streamer displays.
- Profile: User identity and theming.
- Secrets: Redaction patterns and encryption keys.
## Interfaces
- REST API endpoints for session, snippets, redaction, export, profile, overlays.
- WebSocket/SSE for overlay live updates.
- SDKs (Node, Python, Go) with idempotency and retries.
## Security & Privacy
- OAuth2 client credentials and PATs with scoped permissions.
- Session URLs signed and expiring via JWT/JWE.
- Deterministic redaction and entropy heuristics.
- Zero-knowledge encryption option with user-managed keys.
- Append-only audit logs for admin actions and sensitive read records.
## Scalability
- Single-node for self-host users.
- HA cluster option with PostgreSQL replication and Redis clustering for teams.
"""
tasks_md = """# Implementation Tasks
## Milestone 1: Core GA
- Implement API with full session and snippet CRUD (FR-001).
- Context window expansion and raw formatting endpoint (FR-002, FR-003).
- Session summaries, stats, session export/import (FR-004, FR-007).
- Docker Compose setup for local dev and initial deployment.
- Initial SDK implementations with retries.
## Milestone 2: Extensions & Daemon
- Develop browser extension for snippet capture with provenance (FR-009).
- Develop clipboard daemon for system clipboard capture and enforced redaction rules (FR-010).
- Integrate worker jobs for redaction and sanitation (FR-005, FR-006).
## Milestone 3: Overlays
- Build overlay service supporting streamer mode and OBS integration (FR-008).
- Implement WebSocket events for real-time overlay updates.
## Milestone 4: Profiles & Themes
- Implement user profiles with identity and theming support (FR-011).
- Web UI enhancements for profile/theme management.
## Milestone 5: Operational Hardening
- Metrics collection and SLO enforcement (NFR-001 to NFR-004).
- Backup and restore workflows.
- Enhanced logging, alerting, and runbooks.
"""
gitignore_content = """# Node.js
node_modules/
dist/
.env
# Logs
logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
"""
license_content = """MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
[Full MIT license text...]
"""
readme_content = """# Kites
Kites is a secure, structured snippet service designed for AI agent CLIs, developers, teams, self-hosters, and streamers. This project follows spec-driven development with GitHub Spec Kit for agentic operation.
## Structure
- api/: Backend API service
- worker-jobs/: Background jobs for redaction, summaries, sanitation
- web-ui/: Next.js management and monitoring UI
- overlay-service/: Streamer overlays with OBS integration
- browser-extension/: Browser extension for capture
- clipboard-daemon/: Clipboard capture and enforcement daemon
- storage/: Database and storage configuration
- sdk/: Client SDKs for integrations
- docs/: Documentation and runbooks
- tests/: End-to-end tests
- scripts/: Deployment and operational scripts
- config/: Configuration files and secrets management
- metrics/: Metrics and alerting setup
- security/: Security tooling and audit logs
- spec-kit/: GitHub Spec Kit for specs, plan, and tasks
## Getting Started
TODO: Add development and deployment instructions.
"""
# Write main repo files
with open(os.path.join(root_dir, ".gitignore"), "w") as f:
f.write(gitignore_content)
with open(os.path.join(root_dir, "LICENSE"), "w") as f:
f.write(license_content)
with open(os.path.join(root_dir, "README.md"), "w") as f:
f.write(readme_content)
# Write stub README.md files in each subfolder
for folder in subfolders:
readme_path = os.path.join(root_dir, folder, "README.md")
with open(readme_path, "w") as f:
folder_name = folder.replace("-", " ").title()
f.write(f"# {folder_name}\n\nPlaceholder README for {folder}.")
# Write Spec Kit markdown files
with open(os.path.join(root_dir, "spec-kit", "spec", "feature-spec.md"), "w") as f:
f.write(feature_spec_md)
with open(os.path.join(root_dir, "spec-kit", "plan", "technical-plan.md"), "w") as f:
f.write(technical_plan_md)
with open(os.path.join(root_dir, "spec-kit", "tasks", "implementation-tasks.md"), "w") as f:
f.write(tasks_md)
print("GitHub repo-ready Kites project structure generated successfully.")

13
kites/.gitignore vendored
View file

@ -1,13 +0,0 @@
# Node.js
node_modules/
dist/
.env
# Logs
logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db

View file

@ -1,12 +0,0 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
[Full MIT license text...]

View file

@ -1,26 +0,0 @@
# Kites
Kites is a secure, structured snippet service designed for AI agent CLIs, developers, teams, self-hosters, and streamers. This project follows spec-driven development with GitHub Spec Kit for agentic operation.
## Structure
- api/: Backend API service
- worker-jobs/: Background jobs for redaction, summaries, sanitation
- web-ui/: Next.js management and monitoring UI
- overlay-service/: Streamer overlays with OBS integration
- browser-extension/: Browser extension for capture
- clipboard-daemon/: Clipboard capture and enforcement daemon
- storage/: Database and storage configuration
- sdk/: Client SDKs for integrations
- docs/: Documentation and runbooks
- tests/: End-to-end tests
- scripts/: Deployment and operational scripts
- config/: Configuration files and secrets management
- metrics/: Metrics and alerting setup
- security/: Security tooling and audit logs
- spec-kit/: GitHub Spec Kit for specs, plan, and tasks
## Getting Started
TODO: Add development and deployment instructions.

View file

@ -1,3 +0,0 @@
# Api
Placeholder README for api.

View file

@ -1,3 +0,0 @@
# Browser Extension
Placeholder README for browser-extension.

View file

@ -1,3 +0,0 @@
# Clipboard Daemon
Placeholder README for clipboard-daemon.

View file

@ -1,3 +0,0 @@
# Config
Placeholder README for config.

View file

@ -1,3 +0,0 @@
# Docs
Placeholder README for docs.

View file

@ -1,3 +0,0 @@
# Metrics
Placeholder README for metrics.

View file

@ -1,3 +0,0 @@
# Overlay Service
Placeholder README for overlay-service.

View file

@ -1,3 +0,0 @@
# Scripts
Placeholder README for scripts.

View file

@ -1,3 +0,0 @@
# Sdk
Placeholder README for sdk.

View file

@ -1,3 +0,0 @@
# Security
Placeholder README for security.

View file

@ -1,3 +0,0 @@
# Spec Kit/Plan
Placeholder README for spec-kit/plan.

View file

@ -1,33 +0,0 @@
# Technical Plan
## Architecture & Stack
- API: Fastify (Node.js) with OpenAPI v3 spec, alternative Go backend planned.
- Worker jobs: asynchronous redaction, summaries, screenshot sanitation.
- Web UI: Next.js for management and monitoring.
- Overlay service: supports streaming software (OBS).
- Browser extension: MV3, captures page selections.
- Clipboard daemon: Electron/Tauri app for system clipboard capture and rule enforcement.
- Storage: PostgreSQL (prod), SQLite (dev), Redis for job management, S3/MinIO for binaries/screenshots.
## Key Models
- Session: Aggregates snippets, context windows, metadata.
- Snippet: Individual captured content with tags and categories.
- Overlay: Configuration for streamer displays.
- Profile: User identity and theming.
- Secrets: Redaction patterns and encryption keys.
## Interfaces
- REST API endpoints for session, snippets, redaction, export, profile, overlays.
- WebSocket/SSE for overlay live updates.
- SDKs (Node, Python, Go) with idempotency and retries.
## Security & Privacy
- OAuth2 client credentials and PATs with scoped permissions.
- Session URLs signed and expiring via JWT/JWE.
- Deterministic redaction and entropy heuristics.
- Zero-knowledge encryption option with user-managed keys.
- Append-only audit logs for admin actions and sensitive read records.
## Scalability
- Single-node for self-host users.
- HA cluster option with PostgreSQL replication and Redis clustering for teams.

View file

@ -1,3 +0,0 @@
# Spec Kit/Spec
Placeholder README for spec-kit/spec.

View file

@ -1,28 +0,0 @@
# Feature Spec
## Overview
Kites is a secure, structured snippet service for curated text, code, and images. It supports AI agent CLIs, developers, teams, self-hosters, and streamers. The system prioritizes reliable snippet capture, privacy-preserving self-hosting, fast retrieval, CLI-ready formatting, and streamer-friendly overlays.
## Goals
- Enable fast, reliable snippet capture with p95 write latency under 400ms.
- Ensure full secret redaction with 100% test coverage.
- Enable session import/export with no data loss.
- Provide overlays for streamers with real-time updates.
- Allow AI agents full programmatic access for autonomous operation.
- Operate self-hosted with strong privacy and security.
## User Stories
- As an AI agent CLI, I want to programmatically create, read, update, and delete snippets with context so I can automate snippet handling.
- As a developer, I want fast read/write APIs with low latency and raw formatted snippet retrieval for seamless CLI integration.
- As a streamer, I want live snippet overlays in OBS with low latency for dynamic display on streams.
- As a self-hosting team, I want privacy controls including secret redaction, zero-knowledge mode, and audit logging.
- As an end-user, I want a browser extension and clipboard daemon to capture snippets with metadata and enforce security policies.
## Acceptance Criteria
- Snippet CRUD with tags, categories, and context expansion works with latency goals met.
- Raw snippet retrieval excludes line numbers and matches displayed content exactly.
- Secret redaction masks all seeded secrets in test corpus.
- Session URLs expire and revoked sessions return HTTP 410.
- Overlay updates propagate to OBS with ≤150ms p95 latency.
- Clipboard daemon blocks sensitive data automatically.
- Browser extension captures page selections with provenance metadata.

View file

@ -1,3 +0,0 @@
# Spec Kit/Tasks
Placeholder README for spec-kit/tasks.

View file

@ -1,26 +0,0 @@
# Implementation Tasks
## Milestone 1: Core GA
- Implement API with full session and snippet CRUD (FR-001).
- Context window expansion and raw formatting endpoint (FR-002, FR-003).
- Session summaries, stats, session export/import (FR-004, FR-007).
- Docker Compose setup for local dev and initial deployment.
- Initial SDK implementations with retries.
## Milestone 2: Extensions & Daemon
- Develop browser extension for snippet capture with provenance (FR-009).
- Develop clipboard daemon for system clipboard capture and enforced redaction rules (FR-010).
- Integrate worker jobs for redaction and sanitation (FR-005, FR-006).
## Milestone 3: Overlays
- Build overlay service supporting streamer mode and OBS integration (FR-008).
- Implement WebSocket events for real-time overlay updates.
## Milestone 4: Profiles & Themes
- Implement user profiles with identity and theming support (FR-011).
- Web UI enhancements for profile/theme management.
## Milestone 5: Operational Hardening
- Metrics collection and SLO enforcement (NFR-001 to NFR-004).
- Backup and restore workflows.
- Enhanced logging, alerting, and runbooks.

View file

@ -1,3 +0,0 @@
# Storage
Placeholder README for storage.

View file

@ -1,3 +0,0 @@
# Tests
Placeholder README for tests.

View file

@ -1,3 +0,0 @@
# Web Ui
Placeholder README for web-ui.

View file

@ -1,3 +0,0 @@
# Worker Jobs
Placeholder README for worker-jobs.

7
next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

8683
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

42
package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "kites",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.11.1",
"@types/react-syntax-highlighter": "^15.5.13",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"lucide-react": "^0.554.0",
"nanoid": "^5.1.6",
"next": "16.0.3",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-syntax-highlighter": "^16.1.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.7",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

BIN
sqlite.db Normal file

Binary file not shown.

View file

@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View file

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { pastes } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { z, ZodError } from 'zod';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { searchParams } = new URL(req.url);
const raw = searchParams.get('raw') === '1';
const paste = await db.select().from(pastes).where(eq(pastes.id, id)).get();
if (!paste) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (raw) {
return new NextResponse(paste.content, {
headers: {
'Content-Type': paste.contentType || 'text/plain',
},
});
}
return NextResponse.json(paste);
}
const updatePasteSchema = z.object({
title: z.string().optional(),
content: z.string().optional(),
visibility: z.enum(['public', 'unlisted', 'private']).optional(),
syntax: z.string().optional(),
});
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await req.json();
const validated = updatePasteSchema.parse(body);
const updated = await db.update(pastes)
.set({
...validated,
updatedAt: new Date(),
})
.where(eq(pastes.id, id))
.returning()
.get();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json({ error: (error as any).errors }, { status: 400 });
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await db.delete(pastes).where(eq(pastes.id, id)).run();
return new NextResponse(null, { status: 204 });
}

View file

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { pastes, tags, pastesToTags } from '@/db/schema';
import { nanoid } from 'nanoid';
import { z, ZodError } from 'zod';
import { eq, desc, like, and } from 'drizzle-orm';
import { auth } from '@/auth';
const createPasteSchema = z.object({
content: z.string().min(1),
title: z.string().optional(),
tags: z.array(z.string()).optional(),
sessionId: z.string().optional(),
visibility: z.enum(['public', 'unlisted', 'private']).optional().default('public'),
expiresIn: z.number().optional(), // seconds
contentType: z.string().optional(),
syntax: z.string().optional(),
});
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const session = await auth();
const validated = createPasteSchema.parse(body);
const id = nanoid(10);
// Calculate expiration
let expiresAt = null;
if (validated.expiresIn) {
// Simple expiration logic (seconds)
expiresAt = new Date(Date.now() + validated.expiresIn * 1000);
}
await db.insert(pastes).values({
id,
content: validated.content,
title: validated.title,
sessionId: validated.sessionId,
visibility: validated.visibility,
expiresAt,
contentType: validated.contentType,
syntax: validated.syntax,
authorId: session?.user?.id,
});
if (validated.tags && validated.tags.length > 0) {
for (const tagName of validated.tags) {
// Simple tag handling: create if not exists, then link
// In a real app, might want to optimize this
let tagId = nanoid(8);
// Check if tag exists
const existingTag = await db.select().from(tags).where(eq(tags.name, tagName)).get();
if (existingTag) {
tagId = existingTag.id;
} else {
await db.insert(tags).values({ id: tagId, name: tagName });
}
await db.insert(pastesToTags).values({
pasteId: id,
tagId: tagId,
});
}
}
const created = await db.select().from(pastes).where(eq(pastes.id, id)).get();
return NextResponse.json(created, { status: 201 });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json({ error: (error as any).errors }, { status: 400 });
}
console.error(error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limit = parseInt(searchParams.get('limit') || '20');
const offset = parseInt(searchParams.get('offset') || '0');
const sessionId = searchParams.get('session_id');
const authorId = searchParams.get('author_id');
const q = searchParams.get('q');
// Basic filtering
let conditions = [];
if (sessionId) conditions.push(eq(pastes.sessionId, sessionId));
if (authorId) conditions.push(eq(pastes.authorId, authorId));
if (q) conditions.push(like(pastes.title, `%${q}%`)); // Simple title search
// TODO: Tag filtering requires join
const results = await db.select()
.from(pastes)
.where(and(...conditions))
.orderBy(desc(pastes.createdAt))
.limit(limit)
.offset(offset)
.all();
return NextResponse.json(results);
}

View file

@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { pastes } from '@/db/schema';
import { eq, desc } from 'drizzle-orm';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const results = await db.select()
.from(pastes)
.where(eq(pastes.sessionId, sessionId))
.orderBy(desc(pastes.createdAt))
.all();
return NextResponse.json(results);
}

View file

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { agentSessions } from '@/db/schema';
import { nanoid } from 'nanoid';
import { z, ZodError } from 'zod';
import { eq } from 'drizzle-orm';
import { auth } from '@/auth';
const createSessionSchema = z.object({
title: z.string().optional(),
agentName: z.string().optional(),
});
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const session = await auth();
const validated = createSessionSchema.parse(body);
const id = nanoid(12);
await db.insert(agentSessions).values({
id,
title: validated.title,
agentName: validated.agentName,
userId: session?.user?.id,
});
const created = await db.select().from(agentSessions).where(eq(agentSessions.id, id)).get(); // Re-fetch to get default fields if any
// Since we inserted, we can just return what we have + id if we trust it, but fetching is safer for timestamps
// Actually better-sqlite3 insert doesn't return the row by default unless using returning() which is supported in Drizzle now
// Let's use returning() in the insert above if possible, but I used .values().
// Drizzle with SQLite supports .returning()
// Let's refactor to use returning() for cleaner code in next iteration or just re-fetch.
// Re-fetching is fine.
return NextResponse.json({ id, ...validated }, { status: 201 });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json({ error: (error as any).errors }, { status: 400 });
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

100
src/app/globals.css Normal file
View file

@ -0,0 +1,100 @@
@import "tailwindcss";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

39
src/app/layout.tsx Normal file
View file

@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Navbar } from "@/components/navbar";
import { cn } from "@/lib/utils";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Kites - Agentic Pastebin",
description: "A clean, agent-native pastebin service.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={cn(inter.className, "min-h-screen bg-background font-sans antialiased")}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="relative flex min-h-screen flex-col">
<Navbar />
<main className="flex-1 container mx-auto px-4 py-6">
{children}
</main>
</div>
</ThemeProvider>
</body>
</html>
);
}

10
src/app/new/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import { PasteForm } from "@/components/paste-form";
export default function NewPastePage() {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold tracking-tight mb-6">Create New Paste</h1>
<PasteForm />
</div>
);
}

65
src/app/page.tsx Normal file
View file

@ -0,0 +1,65 @@
import Link from "next/link";
import { db } from "@/db";
import { pastes } from "@/db/schema";
import { desc } from "drizzle-orm";
import { formatDistanceToNow } from "date-fns";
export const dynamic = 'force-dynamic';
export default async function Home() {
const recentPastes = await db.select()
.from(pastes)
.orderBy(desc(pastes.createdAt))
.limit(20)
.all();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Recent Pastes</h1>
<Link
href="/new"
className="inline-flex items-center justify-center 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 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
>
New Paste
</Link>
</div>
<div className="grid gap-4">
{recentPastes.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No pastes found. Create one to get started.
</div>
) : (
recentPastes.map((paste) => (
<Link
key={paste.id}
href={`/paste/${paste.id}`}
className="block p-6 rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg truncate">
{paste.title || "Untitled Paste"}
</h2>
<span className="text-xs text-muted-foreground font-mono">
{paste.syntax || "text"}
</span>
</div>
<div className="text-sm text-muted-foreground line-clamp-2 font-mono bg-muted/50 p-2 rounded">
{paste.content}
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<span>{formatDistanceToNow(paste.createdAt!, { addSuffix: true })}</span>
{paste.visibility !== 'public' && (
<span className="capitalize px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{paste.visibility}
</span>
)}
</div>
</Link>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { db } from "@/db";
import { pastes, tags, pastesToTags } from "@/db/schema";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { CodeViewer } from "@/components/code-viewer";
import Link from "next/link";
import { Badge } from "@/components/ui/badge"; // We don't have this yet, I'll inline styles or create it
// Inline simple Badge component for now to avoid creating too many files
function SimpleBadge({ children, className }: { children: React.ReactNode, className?: string }) {
return (
<span className={`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 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 ${className}`}>
{children}
</span>
);
}
export default async function PastePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const paste = await db.select().from(pastes).where(eq(pastes.id, id)).get();
if (!paste) {
notFound();
}
const pasteTags = await db.select({
id: tags.id,
name: tags.name,
})
.from(tags)
.innerJoin(pastesToTags, eq(tags.id, pastesToTags.tagId))
.where(eq(pastesToTags.pasteId, paste.id))
.all();
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{paste.title || "Untitled Paste"}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{formatDistanceToNow(paste.createdAt!, { addSuffix: true })}</span>
<span></span>
<span className="font-mono">{paste.syntax || "text"}</span>
<span></span>
<span className="capitalize">{paste.visibility}</span>
</div>
</div>
<div className="flex items-center gap-2">
<a
href={`/api/v1/pastes/${paste.id}?raw=1`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center 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 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
>
Raw
</a>
{/* Copy button could go here */}
</div>
</div>
{pasteTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{pasteTags.map((tag) => (
<Link key={tag.id} href={`/tag/${tag.id}`}>
<SimpleBadge>#{tag.name}</SimpleBadge>
</Link>
))}
</div>
)}
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
<CodeViewer code={paste.content} language={paste.syntax || 'text'} />
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import Link from "next/link";
import { db } from "@/db";
import { agentSessions, pastes } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { notFound } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
export default async function SessionPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await db.select().from(agentSessions).where(eq(agentSessions.id, id)).get();
if (!session) {
notFound();
}
const sessionPastes = await db.select()
.from(pastes)
.where(eq(pastes.sessionId, session.id))
.orderBy(desc(pastes.createdAt))
.all();
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{session.title || "Session " + session.id}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{formatDistanceToNow(session.createdAt!, { addSuffix: true })}</span>
<span></span>
<span className="font-mono">{session.agentName || "Unknown Agent"}</span>
</div>
</div>
<div className="grid gap-4">
{sessionPastes.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No pastes in this session.
</div>
) : (
sessionPastes.map((paste) => (
<Link
key={paste.id}
href={`/paste/${paste.id}`}
className="block p-4 rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between mb-1">
<h3 className="font-medium truncate">
{paste.title || "Untitled Paste"}
</h3>
<span className="text-xs text-muted-foreground font-mono">
{paste.syntax || "text"}
</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-2 font-mono bg-muted/50 p-1.5 rounded mb-2">
{paste.content}
</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(paste.createdAt!, { addSuffix: true })}
</div>
</Link>
))
)}
</div>
</div>
);
}

58
src/app/sessions/page.tsx Normal file
View file

@ -0,0 +1,58 @@
import Link from "next/link";
import { db } from "@/db";
import { agentSessions, pastes } from "@/db/schema";
import { desc, eq, sql } from "drizzle-orm";
import { formatDistanceToNow } from "date-fns";
export const dynamic = 'force-dynamic';
export default async function SessionsPage() {
// Fetch sessions with paste count
const allSessions = await db.select({
id: agentSessions.id,
title: agentSessions.title,
agentName: agentSessions.agentName,
createdAt: agentSessions.createdAt,
pasteCount: sql<number>`count(${pastes.id})`,
})
.from(agentSessions)
.leftJoin(pastes, eq(agentSessions.id, pastes.sessionId))
.groupBy(agentSessions.id)
.orderBy(desc(agentSessions.createdAt))
.all();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Sessions</h1>
<div className="grid gap-4">
{allSessions.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No sessions found.
</div>
) : (
allSessions.map((session) => (
<Link
key={session.id}
href={`/sessions/${session.id}`}
className="block p-6 rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">
{session.title || session.id}
</h2>
<span className="text-xs text-muted-foreground font-mono">
{session.agentName || "Unknown Agent"}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{formatDistanceToNow(session.createdAt!, { addSuffix: true })}</span>
<span>{session.pasteCount} pastes</span>
</div>
</Link>
))
)}
</div>
</div>
);
}

75
src/app/tag/[id]/page.tsx Normal file
View file

@ -0,0 +1,75 @@
import Link from "next/link";
import { db } from "@/db";
import { tags, pastes, pastesToTags } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { notFound } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { Badge } from "@/components/ui/badge";
export default async function TagPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const tag = await db.select().from(tags).where(eq(tags.id, id)).get();
if (!tag) {
notFound();
}
const taggedPastes = await db.select({
id: pastes.id,
title: pastes.title,
content: pastes.content,
syntax: pastes.syntax,
createdAt: pastes.createdAt,
visibility: pastes.visibility,
})
.from(pastes)
.innerJoin(pastesToTags, eq(pastes.id, pastesToTags.pasteId))
.where(eq(pastesToTags.tagId, tag.id))
.orderBy(desc(pastes.createdAt))
.all();
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">Tag:</h1>
<Badge className="text-lg px-3 py-1">#{tag.name}</Badge>
</div>
<div className="grid gap-4">
{taggedPastes.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No pastes found with this tag.
</div>
) : (
taggedPastes.map((paste) => (
<Link
key={paste.id}
href={`/paste/${paste.id}`}
className="block p-6 rounded-lg border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg truncate">
{paste.title || "Untitled Paste"}
</h2>
<span className="text-xs text-muted-foreground font-mono">
{paste.syntax || "text"}
</span>
</div>
<div className="text-sm text-muted-foreground line-clamp-2 font-mono bg-muted/50 p-2 rounded">
{paste.content}
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<span>{formatDistanceToNow(paste.createdAt!, { addSuffix: true })}</span>
{paste.visibility !== 'public' && (
<span className="capitalize px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{paste.visibility}
</span>
)}
</div>
</Link>
))
)}
</div>
</div>
);
}

17
src/auth.ts Normal file
View file

@ -0,0 +1,17 @@
import NextAuth from "next-auth"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/db"
import { accounts, sessions, users, verificationTokens } from "@/db/schema"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import Apple from "next-auth/providers/apple"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [Google, GitHub, Apple],
})

View file

@ -0,0 +1,30 @@
import { signIn, signOut } from "@/auth"
import { Button } from "./ui/button"
export function SignIn() {
return (
<form
action={async () => {
"use server"
await signIn()
}}
>
<Button variant="outline" size="sm">Sign In</Button>
</form>
)
}
export function SignOut() {
return (
<form
action={async () => {
"use server"
await signOut()
}}
>
<Button variant="ghost" size="sm" className="w-full justify-start">
Sign Out
</Button>
</form>
)
}

View file

@ -0,0 +1,39 @@
"use client"
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
interface CodeViewerProps {
code: string;
language: string;
}
export function CodeViewer({ code, language }: CodeViewerProps) {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<pre className="p-4 rounded-md bg-muted overflow-auto">
<code>{code}</code>
</pre>
);
}
return (
<SyntaxHighlighter
language={language || 'text'}
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{ margin: 0, borderRadius: '0.5rem', fontSize: '0.875rem' }}
showLineNumbers
>
{code}
</SyntaxHighlighter>
);
}

29
src/components/navbar.tsx Normal file
View file

@ -0,0 +1,29 @@
import Link from "next/link"
import { ThemeToggle } from "./theme-toggle"
import UserButton from "./user-button"
export function Navbar() {
return (
<header className="border-b">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-6">
<Link href="/" className="font-bold text-xl tracking-tight">
Kites
</Link>
<nav className="flex items-center gap-4 text-sm font-medium text-muted-foreground">
<Link href="/" className="hover:text-foreground transition-colors">
Pastes
</Link>
<Link href="/sessions" className="hover:text-foreground transition-colors">
Sessions
</Link>
</nav>
</div>
<div className="flex items-center gap-4">
<ThemeToggle />
<UserButton />
</div>
</div>
</header>
)
}

View file

@ -0,0 +1,141 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
export function PasteForm() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
setError(null)
const formData = new FormData(event.currentTarget)
const data = {
title: formData.get("title"),
content: formData.get("content"),
syntax: formData.get("syntax"),
visibility: formData.get("visibility"),
tags: formData.get("tags")?.toString().split(",").map(t => t.trim()).filter(Boolean),
}
try {
const res = await fetch("/api/v1/pastes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) {
const json = await res.json()
throw new Error(JSON.stringify(json.error) || "Failed to create paste")
}
const created = await res.json()
router.push(`/paste/${created.id}`)
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="title" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Title (Optional)
</label>
<input
id="title"
name="title"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
placeholder="My awesome snippet"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="syntax" className="text-sm font-medium leading-none">
Syntax
</label>
<select
id="syntax"
name="syntax"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="text">Plain Text</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="python">Python</option>
<option value="json">JSON</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="bash">Bash</option>
<option value="go">Go</option>
<option value="rust">Rust</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="visibility" className="text-sm font-medium leading-none">
Visibility
</label>
<select
id="visibility"
name="visibility"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Private</option>
</select>
</div>
</div>
<div className="space-y-2">
<label htmlFor="tags" className="text-sm font-medium leading-none">
Tags (comma separated)
</label>
<input
id="tags"
name="tags"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="bug, feature, wip"
/>
</div>
<div className="space-y-2">
<label htmlFor="content" className="text-sm font-medium leading-none">
Content
</label>
<textarea
id="content"
name="content"
required
className="flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background 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"
placeholder="Paste your code here..."
/>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex items-center justify-center 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 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full"
>
{loading ? "Creating..." : "Create Paste"}
</button>
</form>
)
}

View file

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

View file

@ -0,0 +1,21 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Toggle theme"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 top-2" />
<span className="sr-only">Toggle theme</span>
</button>
)
}

View file

@ -0,0 +1,35 @@
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,43 @@
import * as React from "react"
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost"
size?: "default" | "sm" | "lg"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
const variants = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
}
const sizes = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
}
return (
<button
className={cn(
"inline-flex items-center justify-center 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",
variants[variant],
sizes[size],
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

View file

@ -0,0 +1,31 @@
import { auth } from "@/auth"
import { SignIn, SignOut } from "./auth-components"
import { Button } from "./ui/button"
export default async function UserButton() {
const session = await auth()
if (!session?.user) return <SignIn />
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{session.user.image ? (
<img
src={session.user.image}
alt={session.user.name || "User"}
className="w-8 h-8 rounded-full border border-border"
/>
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
{session.user.name?.[0] || "U"}
</div>
)}
<span className="text-sm font-medium hidden md:inline-block">
{session.user.name}
</span>
</div>
<SignOut />
</div>
)
}

6
src/db/index.ts Normal file
View file

@ -0,0 +1,6 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema';
const sqlite = new Database('sqlite.db');
export const db = drizzle(sqlite, { schema });

119
src/db/schema.ts Normal file
View file

@ -0,0 +1,119 @@
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import type { AdapterAccount } from "next-auth/adapters";
// --- Application Tables ---
export const pastes = sqliteTable('pastes', {
id: text('id').primaryKey(), // Nanoid
title: text('title'),
content: text('content').notNull(),
contentType: text('content_type').default('text/plain'),
syntax: text('syntax'),
visibility: text('visibility').default('public'), // 'public', 'unlisted', 'private'
authorId: text('author_id').references(() => users.id, { onDelete: 'set null' }), // Linked to User
sessionId: text('session_id'), // Linked to Agent Session (logical)
expiresAt: integer('expires_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`CURRENT_TIMESTAMP`),
});
export const tags = sqliteTable('tags', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
color: text('color'),
description: text('description'),
});
export const pastesToTags = sqliteTable('pastes_to_tags', {
pasteId: text('paste_id').references(() => pastes.id, { onDelete: 'cascade' }),
tagId: text('tag_id').references(() => tags.id, { onDelete: 'cascade' }),
}, (t) => ({
pk: index('pk').on(t.pasteId, t.tagId),
}));
// Renamed from 'sessions' to 'agent_sessions' to avoid conflict with Auth.js
export const agentSessions = sqliteTable('agent_sessions', {
id: text('id').primaryKey(),
title: text('title'),
agentName: text('agent_name'),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), // Optional owner
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`CURRENT_TIMESTAMP`),
});
// --- Auth.js Tables ---
export const users = sqliteTable("user", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email").notNull(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
});
export const accounts = sqliteTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
export const sessions = sqliteTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
});
export const verificationTokens = sqliteTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
})
);
export const authenticators = sqliteTable(
"authenticator",
{
credentialID: text("credentialID").notNull().unique(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerAccountId: text("providerAccountId").notNull(),
credentialPublicKey: text("credentialPublicKey").notNull(),
counter: integer("counter").notNull(),
credentialDeviceType: text("credentialDeviceType").notNull(),
credentialBackedUp: integer("credentialBackedUp").notNull(),
transports: text("transports"),
},
(authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
})
);

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}