mirror of
https://github.com/johndoe6345789/snippet-pastebin.git
synced 2026-04-24 13:34:55 +00:00
Generated by Spark: Ok I figured it out. Make a backend folder, put a flask backend in it with a Dockerfile. Main UI uses IndexedDB then with a UI setting, it can be moved to the flask backend.
This commit is contained in:
46
PRD.md
46
PRD.md
@@ -60,28 +60,49 @@ A code snippet management application with an integrated component library showc
|
||||
- Success criteria: Demo loads with working example code, users can edit and see instant changes, educational cards explain key features
|
||||
|
||||
**Database Management & Settings**
|
||||
- Functionality: Settings page with database statistics, backup/restore, and storage information
|
||||
- Purpose: Enable users to manage their local data, export/import snippets, and understand storage mechanism
|
||||
- Functionality: Settings page with storage backend selection (IndexedDB or Flask), database statistics, backup/restore, and data migration
|
||||
- Purpose: Enable users to choose between local browser storage or remote Flask backend, manage their data, export/import snippets, and migrate between storage backends
|
||||
- Trigger: Navigate to "Settings" via hamburger menu
|
||||
- Progression: User opens settings → Views database stats → Exports backup if needed → Can import previous backups → Manages sample data → Can clear all data if needed
|
||||
- Success criteria: Shows accurate statistics, export creates valid .db file, import restores data correctly, clear operation requires confirmation
|
||||
- Progression: User opens settings → Selects storage backend (IndexedDB or Flask) → Configures Flask URL if needed → Tests connection → Migrates data if switching backends → Views database stats → Exports backup if needed → Can import previous backups → Manages sample data → Can clear all data if needed
|
||||
- Success criteria: Backend switching works seamlessly, Flask connection test validates server availability, data migration preserves all snippets, shows accurate statistics, export creates valid .db file, import restores data correctly, clear operation requires confirmation
|
||||
|
||||
## Data Persistence
|
||||
|
||||
The application uses **SQL.js** (SQLite compiled to WebAssembly) for local database management with the following storage strategy:
|
||||
The application supports **flexible data storage** with two backend options:
|
||||
|
||||
1. **Primary Storage: IndexedDB** - Used when available for better performance and larger storage capacity (typically 50MB+ depending on browser)
|
||||
2. **Fallback: localStorage** - Used when IndexedDB is unavailable (typically 5-10MB limit)
|
||||
3. **Database Structure**: Two tables - `snippets` (user-created snippets) and `snippet_templates` (reusable templates)
|
||||
4. **Automatic Persistence**: Database is automatically saved after every create, update, or delete operation
|
||||
5. **Export/Import**: Users can export their entire database as a .db file for backup or transfer to another device
|
||||
### Storage Backends
|
||||
|
||||
1. **IndexedDB (Local Browser Storage) - Default**
|
||||
- Uses SQL.js (SQLite compiled to WebAssembly) for local database management
|
||||
- Primary Storage: IndexedDB - Used when available for better performance and larger storage capacity (typically 50MB+)
|
||||
- Fallback: localStorage - Used when IndexedDB is unavailable (typically 5-10MB limit)
|
||||
- Database Structure: Two tables - `snippets` (user-created snippets) and `snippet_templates` (reusable templates)
|
||||
- Automatic Persistence: Database is automatically saved after every create, update, or delete operation
|
||||
- Export/Import: Users can export their entire database as a .db file for backup or transfer
|
||||
|
||||
2. **Flask Backend (Remote Server) - Optional**
|
||||
- Snippets stored on a Flask REST API server with SQLite database
|
||||
- Allows access to snippets from any device
|
||||
- Requires running the Flask backend (Docker support included)
|
||||
- RESTful API endpoints for all CRUD operations
|
||||
- Data migration tools to move snippets between IndexedDB and Flask
|
||||
|
||||
### Switching Between Backends
|
||||
|
||||
Users can switch storage backends from the Settings page:
|
||||
- Select desired backend (IndexedDB or Flask)
|
||||
- Configure Flask URL if using remote backend
|
||||
- Test connection to Flask server
|
||||
- Migrate existing snippets between backends
|
||||
- Configuration persists in localStorage
|
||||
|
||||
This approach provides:
|
||||
- Full SQL query capabilities for complex filtering and sorting
|
||||
- Choice between local-only or remote storage
|
||||
- Reliable persistence across browser sessions
|
||||
- No external dependencies or server requirements
|
||||
- Easy backup and restore functionality
|
||||
- Protection against localStorage quota exceeded errors
|
||||
- Multi-device access when using Flask backend
|
||||
|
||||
## Edge Case Handling
|
||||
- **No Search Results**: Friendly message encouraging users to refine their search
|
||||
@@ -91,6 +112,9 @@ This approach provides:
|
||||
- **Storage Quota Exceeded**: Automatically switches from localStorage to IndexedDB if available, warns user if both are full
|
||||
- **Database Corruption**: Gracefully handles corrupted database files, creates new database if loading fails
|
||||
- **Import Invalid Database**: Validates imported files, shows clear error message if file is invalid
|
||||
- **Flask Connection Failure**: Tests connection before switching to Flask backend, shows clear error if server is unreachable
|
||||
- **Data Migration Errors**: Validates data during migration, provides clear feedback on success or failure
|
||||
- **Network Errors with Flask**: Shows informative error messages when Flask API calls fail, suggests checking server status
|
||||
|
||||
## Design Direction
|
||||
The design should evoke **precision, technical craftsmanship, and modern developer tools**.
|
||||
|
||||
@@ -14,33 +14,70 @@ A powerful code snippet management application with an integrated component libr
|
||||
|
||||
## Data Storage
|
||||
|
||||
CodeSnippet uses a robust local storage solution that works entirely in your browser:
|
||||
CodeSnippet offers flexible data storage with two backend options:
|
||||
|
||||
### Storage Strategy
|
||||
1. **Primary: IndexedDB** - Used when available for better performance and larger storage capacity (typically 50MB+)
|
||||
2. **Fallback: localStorage** - Used when IndexedDB is unavailable (typically 5-10MB limit)
|
||||
3. **Technology: SQL.js** - SQLite compiled to WebAssembly for full SQL query capabilities
|
||||
### Storage Backends
|
||||
|
||||
### Database Structure
|
||||
- **snippets** table - Stores user-created code snippets with metadata
|
||||
- **snippet_templates** table - Stores reusable snippet templates
|
||||
#### 1. **IndexedDB (Local Browser Storage)** - Default
|
||||
- All data stored locally in your browser
|
||||
- No server required
|
||||
- Data persists on this device only
|
||||
- Uses SQLite compiled to WebAssembly for full SQL capabilities
|
||||
- Primary: IndexedDB for better performance and larger storage capacity (typically 50MB+)
|
||||
- Fallback: localStorage when IndexedDB is unavailable (typically 5-10MB limit)
|
||||
|
||||
### Features
|
||||
- ✅ Automatic persistence after every operation
|
||||
- ✅ Full SQL query capabilities for complex filtering and sorting
|
||||
- ✅ No external dependencies or server requirements
|
||||
- ✅ Export/import functionality for backup and transfer
|
||||
- ✅ Graceful fallback between storage mechanisms
|
||||
- ✅ Protection against quota exceeded errors
|
||||
#### 2. **Flask Backend (Remote Server)** - Optional
|
||||
- Snippets stored on a remote Flask server with SQLite database
|
||||
- Access your snippets from any device
|
||||
- Requires running the Flask backend (see Backend Setup below)
|
||||
- Supports data migration between IndexedDB and Flask
|
||||
|
||||
### Managing Your Data
|
||||
### Switching Storage Backends
|
||||
|
||||
Visit the **Settings** page (accessible from the hamburger menu) to:
|
||||
- View database statistics (snippet count, template count, storage type, database size)
|
||||
- Export your database as a backup file
|
||||
- Import a previously exported database
|
||||
- Add sample data to get started
|
||||
- Clear all data if needed
|
||||
Visit the **Settings** page to:
|
||||
- Choose between IndexedDB and Flask backend
|
||||
- Configure Flask backend URL
|
||||
- Test connection to Flask server
|
||||
- Migrate data between storage backends
|
||||
- View database statistics
|
||||
- Export/import database backups
|
||||
|
||||
## Backend Setup
|
||||
|
||||
### Running Flask Backend Locally
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
Server runs on `http://localhost:5000` by default.
|
||||
|
||||
### Running with Docker
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
docker build -t codesnippet-backend ./backend
|
||||
docker run -p 5000:5000 -v $(pwd)/data:/data codesnippet-backend
|
||||
```
|
||||
|
||||
Or use docker-compose:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Backend API
|
||||
|
||||
The Flask backend provides a REST API:
|
||||
- `GET /health` - Health check
|
||||
- `GET /api/snippets` - Get all snippets
|
||||
- `GET /api/snippets/:id` - Get a specific snippet
|
||||
- `POST /api/snippets` - Create a new snippet
|
||||
- `PUT /api/snippets/:id` - Update a snippet
|
||||
- `DELETE /api/snippets/:id` - Delete a snippet
|
||||
|
||||
See `backend/README.md` for more details.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
13
backend/.dockerignore
Normal file
13
backend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.env
|
||||
venv
|
||||
env
|
||||
25
backend/.gitignore
vendored
Normal file
25
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
*.db
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py .
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENV DB_PATH=/data/snippets.db
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
154
backend/README.md
Normal file
154
backend/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# CodeSnippet Backend
|
||||
|
||||
Flask-based REST API for managing code snippets with SQLite database.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The backend will be available at `http://localhost:5000`
|
||||
|
||||
### Option 2: Local Python
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
Server runs on `http://localhost:5000` by default.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the `DB_PATH` environment variable to change the database location:
|
||||
|
||||
```bash
|
||||
export DB_PATH=/path/to/snippets.db
|
||||
python app.py
|
||||
```
|
||||
|
||||
Default: `/data/snippets.db`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
- `GET /health` - Returns server health status
|
||||
|
||||
### Snippets
|
||||
- `GET /api/snippets` - Get all snippets
|
||||
- `GET /api/snippets/:id` - Get a specific snippet
|
||||
- `POST /api/snippets` - Create a new snippet
|
||||
- `PUT /api/snippets/:id` - Update a snippet
|
||||
- `DELETE /api/snippets/:id` - Delete a snippet
|
||||
|
||||
### Request/Response Format
|
||||
|
||||
#### Snippet Object
|
||||
```json
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Snippet Title",
|
||||
"code": "const example = 'code';",
|
||||
"language": "javascript",
|
||||
"description": "Optional description",
|
||||
"tags": ["array", "of", "tags"],
|
||||
"category": "general",
|
||||
"componentName": "ComponentName",
|
||||
"previewParams": [],
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
In the CodeSnippet app:
|
||||
1. Navigate to Settings page
|
||||
2. Select "Flask Backend (Remote Server)"
|
||||
3. Enter backend URL (e.g., `http://localhost:5000`)
|
||||
4. Click "Test" to verify connection
|
||||
5. Click "Save Storage Settings"
|
||||
6. Optionally migrate existing IndexedDB data to Flask
|
||||
|
||||
## Docker Details
|
||||
|
||||
### Building the Image
|
||||
|
||||
```bash
|
||||
docker build -t codesnippet-backend ./backend
|
||||
```
|
||||
|
||||
### Running the Container
|
||||
|
||||
```bash
|
||||
# With volume for persistent data
|
||||
docker run -p 5000:5000 -v $(pwd)/data:/data codesnippet-backend
|
||||
|
||||
# With custom database path
|
||||
docker run -p 5000:5000 -e DB_PATH=/data/custom.db -v $(pwd)/data:/data codesnippet-backend
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
# Start
|
||||
docker-compose up -d
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Rebuild after changes
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The backend uses:
|
||||
- **Flask 3.0** - Web framework
|
||||
- **Flask-CORS** - Cross-origin resource sharing
|
||||
- **SQLite3** - Database (built into Python)
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE snippets (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
category TEXT,
|
||||
componentName TEXT,
|
||||
previewParams TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `DB_PATH` - Path to SQLite database file (default: `/data/snippets.db`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
- Ensure the Flask server is running
|
||||
- Check firewall settings
|
||||
- Verify the port (5000) is not in use
|
||||
|
||||
### CORS Errors
|
||||
- The backend allows all origins by default
|
||||
- Modify `CORS(app)` in `app.py` if you need to restrict origins
|
||||
|
||||
### Database Locked
|
||||
- Ensure only one instance of the backend is running
|
||||
- Check file permissions on the database file
|
||||
181
backend/app.py
Normal file
181
backend/app.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
DB_PATH = os.environ.get('DB_PATH', '/data/snippets.db')
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
category TEXT,
|
||||
componentName TEXT,
|
||||
previewParams TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
return jsonify({'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()})
|
||||
|
||||
@app.route('/api/snippets', methods=['GET'])
|
||||
def get_snippets():
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM snippets ORDER BY updatedAt DESC')
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
snippets = []
|
||||
for row in rows:
|
||||
snippet = dict(row)
|
||||
if snippet.get('tags'):
|
||||
snippet['tags'] = json.loads(snippet['tags'])
|
||||
if snippet.get('previewParams'):
|
||||
snippet['previewParams'] = json.loads(snippet['previewParams'])
|
||||
snippets.append(snippet)
|
||||
|
||||
return jsonify(snippets)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/snippets/<snippet_id>', methods=['GET'])
|
||||
def get_snippet(snippet_id):
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM snippets WHERE id = ?', (snippet_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({'error': 'Snippet not found'}), 404
|
||||
|
||||
snippet = dict(row)
|
||||
if snippet.get('tags'):
|
||||
snippet['tags'] = json.loads(snippet['tags'])
|
||||
if snippet.get('previewParams'):
|
||||
snippet['previewParams'] = json.loads(snippet['previewParams'])
|
||||
|
||||
return jsonify(snippet)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/snippets', methods=['POST'])
|
||||
def create_snippet():
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
tags_json = json.dumps(data.get('tags', []))
|
||||
preview_params_json = json.dumps(data.get('previewParams', []))
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO snippets (id, title, code, language, description, tags, category, componentName, previewParams, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
data['id'],
|
||||
data['title'],
|
||||
data['code'],
|
||||
data['language'],
|
||||
data.get('description', ''),
|
||||
tags_json,
|
||||
data.get('category', 'general'),
|
||||
data.get('componentName', ''),
|
||||
preview_params_json,
|
||||
data['createdAt'],
|
||||
data['updatedAt']
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify(data), 201
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/snippets/<snippet_id>', methods=['PUT'])
|
||||
def update_snippet(snippet_id):
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
tags_json = json.dumps(data.get('tags', []))
|
||||
preview_params_json = json.dumps(data.get('previewParams', []))
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE snippets
|
||||
SET title = ?, code = ?, language = ?, description = ?, tags = ?, category = ?, componentName = ?, previewParams = ?, updatedAt = ?
|
||||
WHERE id = ?
|
||||
''', (
|
||||
data['title'],
|
||||
data['code'],
|
||||
data['language'],
|
||||
data.get('description', ''),
|
||||
tags_json,
|
||||
data.get('category', 'general'),
|
||||
data.get('componentName', ''),
|
||||
preview_params_json,
|
||||
data['updatedAt'],
|
||||
snippet_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
return jsonify({'error': 'Snippet not found'}), 404
|
||||
|
||||
return jsonify(data)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/snippets/<snippet_id>', methods=['DELETE'])
|
||||
def delete_snippet(snippet_id):
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM snippets WHERE id = ?', (snippet_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
return jsonify({'error': 'Snippet not found'}), 404
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
2
backend/requirements.txt
Normal file
2
backend/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- snippet-data:/data
|
||||
environment:
|
||||
- DB_PATH=/data/snippets.db
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
snippet-data:
|
||||
@@ -1,8 +1,10 @@
|
||||
import initSqlJs, { Database } from 'sql.js'
|
||||
import type { Snippet, SnippetTemplate } from './types'
|
||||
import { getStorageConfig, FlaskStorageAdapter } from './storage'
|
||||
|
||||
let dbInstance: Database | null = null
|
||||
let sqlInstance: any = null
|
||||
let flaskAdapter: FlaskStorageAdapter | null = null
|
||||
|
||||
const DB_KEY = 'codesnippet-db'
|
||||
const IDB_NAME = 'CodeSnippetDB'
|
||||
@@ -192,7 +194,23 @@ async function saveDB() {
|
||||
}
|
||||
}
|
||||
|
||||
function getFlaskAdapter(): FlaskStorageAdapter | null {
|
||||
const config = getStorageConfig()
|
||||
if (config.backend === 'flask' && config.flaskUrl) {
|
||||
if (!flaskAdapter || flaskAdapter['baseUrl'] !== config.flaskUrl) {
|
||||
flaskAdapter = new FlaskStorageAdapter(config.flaskUrl)
|
||||
}
|
||||
return flaskAdapter
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getAllSnippets(): Promise<Snippet[]> {
|
||||
const adapter = getFlaskAdapter()
|
||||
if (adapter) {
|
||||
return await adapter.getAllSnippets()
|
||||
}
|
||||
|
||||
const db = await initDB()
|
||||
|
||||
const results = db.exec('SELECT * FROM snippets ORDER BY updatedAt DESC')
|
||||
@@ -218,6 +236,11 @@ export async function getAllSnippets(): Promise<Snippet[]> {
|
||||
}
|
||||
|
||||
export async function getSnippet(id: string): Promise<Snippet | null> {
|
||||
const adapter = getFlaskAdapter()
|
||||
if (adapter) {
|
||||
return await adapter.getSnippet(id)
|
||||
}
|
||||
|
||||
const db = await initDB()
|
||||
|
||||
const results = db.exec('SELECT * FROM snippets WHERE id = ?', [id])
|
||||
@@ -242,6 +265,11 @@ export async function getSnippet(id: string): Promise<Snippet | null> {
|
||||
}
|
||||
|
||||
export async function createSnippet(snippet: Snippet): Promise<void> {
|
||||
const adapter = getFlaskAdapter()
|
||||
if (adapter) {
|
||||
return await adapter.createSnippet(snippet)
|
||||
}
|
||||
|
||||
const db = await initDB()
|
||||
|
||||
db.run(
|
||||
@@ -266,6 +294,11 @@ export async function createSnippet(snippet: Snippet): Promise<void> {
|
||||
}
|
||||
|
||||
export async function updateSnippet(snippet: Snippet): Promise<void> {
|
||||
const adapter = getFlaskAdapter()
|
||||
if (adapter) {
|
||||
return await adapter.updateSnippet(snippet)
|
||||
}
|
||||
|
||||
const db = await initDB()
|
||||
|
||||
db.run(
|
||||
@@ -290,6 +323,11 @@ export async function updateSnippet(snippet: Snippet): Promise<void> {
|
||||
}
|
||||
|
||||
export async function deleteSnippet(id: string): Promise<void> {
|
||||
const adapter = getFlaskAdapter()
|
||||
if (adapter) {
|
||||
return await adapter.deleteSnippet(id)
|
||||
}
|
||||
|
||||
const db = await initDB()
|
||||
|
||||
db.run('DELETE FROM snippets WHERE id = ?', [id])
|
||||
|
||||
138
src/lib/storage.ts
Normal file
138
src/lib/storage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Snippet } from './types'
|
||||
|
||||
export type StorageBackend = 'indexeddb' | 'flask'
|
||||
|
||||
export interface StorageConfig {
|
||||
backend: StorageBackend
|
||||
flaskUrl?: string
|
||||
}
|
||||
|
||||
let currentConfig: StorageConfig = {
|
||||
backend: 'indexeddb'
|
||||
}
|
||||
|
||||
const STORAGE_CONFIG_KEY = 'codesnippet-storage-config'
|
||||
|
||||
export function loadStorageConfig(): StorageConfig {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_CONFIG_KEY)
|
||||
if (saved) {
|
||||
currentConfig = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load storage config:', error)
|
||||
}
|
||||
return currentConfig
|
||||
}
|
||||
|
||||
export function saveStorageConfig(config: StorageConfig): void {
|
||||
currentConfig = config
|
||||
try {
|
||||
localStorage.setItem(STORAGE_CONFIG_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save storage config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function getStorageConfig(): StorageConfig {
|
||||
return currentConfig
|
||||
}
|
||||
|
||||
export class FlaskStorageAdapter {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error('Flask connection test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSnippets(): Promise<Snippet[]> {
|
||||
const response = await fetch(`${this.baseUrl}/api/snippets`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch snippets: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.map((s: any) => ({
|
||||
...s,
|
||||
createdAt: typeof s.createdAt === 'string' ? new Date(s.createdAt).getTime() : s.createdAt,
|
||||
updatedAt: typeof s.updatedAt === 'string' ? new Date(s.updatedAt).getTime() : s.updatedAt
|
||||
}))
|
||||
}
|
||||
|
||||
async getSnippet(id: string): Promise<Snippet | null> {
|
||||
const response = await fetch(`${this.baseUrl}/api/snippets/${id}`)
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch snippet: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return {
|
||||
...data,
|
||||
createdAt: typeof data.createdAt === 'string' ? new Date(data.createdAt).getTime() : data.createdAt,
|
||||
updatedAt: typeof data.updatedAt === 'string' ? new Date(data.updatedAt).getTime() : data.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
async createSnippet(snippet: Snippet): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/snippets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...snippet,
|
||||
createdAt: new Date(snippet.createdAt).toISOString(),
|
||||
updatedAt: new Date(snippet.updatedAt).toISOString()
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create snippet: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
async updateSnippet(snippet: Snippet): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/snippets/${snippet.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...snippet,
|
||||
createdAt: new Date(snippet.createdAt).toISOString(),
|
||||
updatedAt: new Date(snippet.updatedAt).toISOString()
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update snippet: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSnippet(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/snippets/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete snippet: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
async migrateFromIndexedDB(snippets: Snippet[]): Promise<void> {
|
||||
for (const snippet of snippets) {
|
||||
await this.createSnippet(snippet)
|
||||
}
|
||||
}
|
||||
|
||||
async migrateToIndexedDB(): Promise<Snippet[]> {
|
||||
return this.getAllSnippets()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import "@github/spark/spark"
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { loadStorageConfig } from '@/lib/storage'
|
||||
|
||||
import App from './App.tsx'
|
||||
import { ErrorFallback } from './ErrorFallback.tsx'
|
||||
@@ -10,6 +11,8 @@ import "./main.css"
|
||||
import "./styles/theme.css"
|
||||
import "./index.css"
|
||||
|
||||
loadStorageConfig()
|
||||
|
||||
const logErrorToConsole = (error: Error, info: { componentStack?: string }) => {
|
||||
console.error('Application Error:', error);
|
||||
if (info.componentStack) {
|
||||
|
||||
@@ -2,10 +2,20 @@ import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Database, Download, Upload, Trash } from '@phosphor-icons/react'
|
||||
import { getDatabaseStats, exportDatabase, importDatabase, clearDatabase, seedDatabase } from '@/lib/db'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Database, Download, Upload, Trash, CloudArrowUp, CloudCheck, CloudSlash } from '@phosphor-icons/react'
|
||||
import { getDatabaseStats, exportDatabase, importDatabase, clearDatabase, seedDatabase, getAllSnippets } from '@/lib/db'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
getStorageConfig,
|
||||
saveStorageConfig,
|
||||
loadStorageConfig,
|
||||
FlaskStorageAdapter,
|
||||
type StorageBackend
|
||||
} from '@/lib/storage'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
|
||||
export function SettingsPage() {
|
||||
const [stats, setStats] = useState<{
|
||||
@@ -15,6 +25,10 @@ export function SettingsPage() {
|
||||
databaseSize: number
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [storageBackend, setStorageBackend] = useState<StorageBackend>('indexeddb')
|
||||
const [flaskUrl, setFlaskUrl] = useState('')
|
||||
const [flaskConnectionStatus, setFlaskConnectionStatus] = useState<'unknown' | 'connected' | 'failed'>('unknown')
|
||||
const [testingConnection, setTestingConnection] = useState(false)
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true)
|
||||
@@ -29,8 +43,30 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const testFlaskConnection = async (url: string) => {
|
||||
setTestingConnection(true)
|
||||
try {
|
||||
const adapter = new FlaskStorageAdapter(url)
|
||||
const connected = await adapter.testConnection()
|
||||
setFlaskConnectionStatus(connected ? 'connected' : 'failed')
|
||||
return connected
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error)
|
||||
setFlaskConnectionStatus('failed')
|
||||
return false
|
||||
} finally {
|
||||
setTestingConnection(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
const config = loadStorageConfig()
|
||||
setStorageBackend(config.backend)
|
||||
setFlaskUrl(config.flaskUrl || 'http://localhost:5000')
|
||||
if (config.backend === 'flask' && config.flaskUrl) {
|
||||
testFlaskConnection(config.flaskUrl)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -104,6 +140,98 @@ export function SettingsPage() {
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
await testFlaskConnection(flaskUrl)
|
||||
}
|
||||
|
||||
const handleSaveStorageConfig = async () => {
|
||||
if (storageBackend === 'flask') {
|
||||
if (!flaskUrl) {
|
||||
toast.error('Please enter a Flask backend URL')
|
||||
return
|
||||
}
|
||||
|
||||
const connected = await testFlaskConnection(flaskUrl)
|
||||
if (!connected) {
|
||||
toast.error('Cannot connect to Flask backend. Please check the URL and ensure the server is running.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saveStorageConfig({
|
||||
backend: storageBackend,
|
||||
flaskUrl: storageBackend === 'flask' ? flaskUrl : undefined
|
||||
})
|
||||
|
||||
toast.success('Storage backend updated successfully')
|
||||
await loadStats()
|
||||
}
|
||||
|
||||
const handleMigrateToFlask = async () => {
|
||||
if (!flaskUrl) {
|
||||
toast.error('Please enter a Flask backend URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const adapter = new FlaskStorageAdapter(flaskUrl)
|
||||
const connected = await adapter.testConnection()
|
||||
|
||||
if (!connected) {
|
||||
toast.error('Cannot connect to Flask backend')
|
||||
return
|
||||
}
|
||||
|
||||
const snippets = await getAllSnippets()
|
||||
|
||||
if (snippets.length === 0) {
|
||||
toast.info('No snippets to migrate')
|
||||
return
|
||||
}
|
||||
|
||||
await adapter.migrateFromIndexedDB(snippets)
|
||||
|
||||
saveStorageConfig({
|
||||
backend: 'flask',
|
||||
flaskUrl
|
||||
})
|
||||
|
||||
toast.success(`Successfully migrated ${snippets.length} snippets to Flask backend`)
|
||||
await loadStats()
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error)
|
||||
toast.error('Failed to migrate data to Flask backend')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMigrateToIndexedDB = async () => {
|
||||
if (!flaskUrl) {
|
||||
toast.error('Please enter a Flask backend URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const adapter = new FlaskStorageAdapter(flaskUrl)
|
||||
const snippets = await adapter.migrateToIndexedDB()
|
||||
|
||||
if (snippets.length === 0) {
|
||||
toast.info('No snippets to migrate')
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageConfig({
|
||||
backend: 'indexeddb'
|
||||
})
|
||||
|
||||
window.location.reload()
|
||||
|
||||
toast.success(`Successfully migrated ${snippets.length} snippets to IndexedDB`)
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error)
|
||||
toast.error('Failed to migrate data from Flask backend')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -116,6 +244,112 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CloudArrowUp weight="duotone" size={24} />
|
||||
Storage Backend
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose where your snippets are stored
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<RadioGroup value={storageBackend} onValueChange={(value) => setStorageBackend(value as StorageBackend)}>
|
||||
<div className="flex items-start space-x-3 space-y-0">
|
||||
<RadioGroupItem value="indexeddb" id="storage-indexeddb" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="storage-indexeddb" className="font-semibold cursor-pointer">
|
||||
IndexedDB (Local Browser Storage)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Store snippets locally in your browser. Data persists on this device only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 space-y-0 mt-4">
|
||||
<RadioGroupItem value="flask" id="storage-flask" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="storage-flask" className="font-semibold cursor-pointer">
|
||||
Flask Backend (Remote Server)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Store snippets on a Flask backend server. Data is accessible from any device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{storageBackend === 'flask' && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-muted/50">
|
||||
<div>
|
||||
<Label htmlFor="flask-url">Flask Backend URL</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
id="flask-url"
|
||||
type="url"
|
||||
placeholder="http://localhost:5000"
|
||||
value={flaskUrl}
|
||||
onChange={(e) => {
|
||||
setFlaskUrl(e.target.value)
|
||||
setFlaskConnectionStatus('unknown')
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
variant="outline"
|
||||
disabled={testingConnection || !flaskUrl}
|
||||
>
|
||||
{testingConnection ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
</div>
|
||||
{flaskConnectionStatus === 'connected' && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-green-600">
|
||||
<CloudCheck weight="fill" size={16} />
|
||||
Connected successfully
|
||||
</div>
|
||||
)}
|
||||
{flaskConnectionStatus === 'failed' && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-destructive">
|
||||
<CloudSlash weight="fill" size={16} />
|
||||
Connection failed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 space-y-2">
|
||||
<Button
|
||||
onClick={handleMigrateToFlask}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Upload weight="bold" size={16} />
|
||||
Migrate IndexedDB Data to Flask
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMigrateToIndexedDB}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Download weight="bold" size={16} />
|
||||
Migrate Flask Data to IndexedDB
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button onClick={handleSaveStorageConfig} className="gap-2">
|
||||
<Database weight="bold" size={16} />
|
||||
Save Storage Settings
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user