diff --git a/.gitignore b/.gitignore index 1437c53..0c901ab 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ yarn-error.log* # vercel .vercel + +# PocketBase data directory +pb_data/ diff --git a/README.md b/README.md index e4f2b01..54416c8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,135 @@ # Interne -## Getting Started +A spaced-repetition bookmark manager that resurfaces saved websites after configurable intervals. -First, run the development server: +## Features + +- Save bookmarks with title, description, and custom revisit intervals +- Entropy-based algorithm resurfaces entries at optimal times +- Multi-user support with authentication +- Search and filter bookmarks +- Keyboard shortcuts (ESC to toggle filter, / for search) +- Responsive design + +## Tech Stack + +**Backend:** + +- PocketBase (Go + SQLite) +- Built-in authentication +- Auto-generated REST APIs +- Admin UI for database management + +**Frontend:** + +- Vite + React 18 + TypeScript +- React Query for server state +- PocketBase JavaScript SDK +- CSS Modules for styling + +## Development + +### Prerequisites + +- Node.js 20+ +- pnpm (`npm install -g pnpm`) +- Docker & Docker Compose + +### Setup + +1. **Start PocketBase:** + + ```bash + docker compose up -d + ``` + +2. **Configure PocketBase:** + - Open + - Create admin account + - Follow setup instructions in `scripts/setup-pocketbase.md` + +3. **Install frontend dependencies:** + + ```bash + cd frontend + pnpm install + ``` + +4. **Start development server:** + + ```bash + pnpm dev + ``` + +5. **Access:** + - Frontend: + - PocketBase Admin: + - PocketBase API: + +### Project Structure + +``` +interne/ +├── docker-compose.yml # PocketBase container config +├── frontend/ # Vite React app +│ ├── src/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ ├── services/ +│ │ ├── styles/ +│ │ ├── types/ +│ │ └── utils/ +│ └── package.json +├── pb_data/ # PocketBase data (gitignored) +└── scripts/ + └── setup-pocketbase.md +``` + +## Deployment + +### Docker + +1. **Update PocketBase URL** in production: + + ```bash + # Set in frontend/.env.production + VITE_POCKETBASE_URL=https://your-domain.com + ``` + +2. **Build frontend:** + + ```bash + cd frontend + pnpm build + ``` + +3. **Run with Docker Compose:** + + ```bash + docker compose up -d + ``` + +4. **Serve frontend** with your preferred static file server (nginx, Caddy, etc.) + +### VPS Deployment + +1. Transfer files to VPS +2. Configure reverse proxy for both PocketBase API and frontend +3. Point domain to server +4. Configure HTTPS with Let's Encrypt + +### Backups + +PocketBase stores all data in the `pb_data` directory: ```bash -npm run dev +# Backup the entire pb_data directory +tar -czf backup-$(date +%Y%m%d).tar.gz pb_data/ + +# Or just the database +docker compose exec pocketbase cp /pb_data/data.db /pb_data/backup.db ``` -Open [http://localhost:4200](http://localhost:4200) with your browser to see the result. +## License + +MIT diff --git a/components/CreateEntryForm.js b/components/CreateEntryForm.js deleted file mode 100644 index 1b9c18b..0000000 --- a/components/CreateEntryForm.js +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' -import PropTypes from 'prop-types' -import { v4 as uuidv4 } from 'uuid' -import { Form, Input, Select, Button } from './Forms' -import { toTitleCase } from '../utils/formatters' -import { INTERVALS } from '../utils/constants' -import { KEY_CODES } from '../utils/constants' -import styles from '../styles/Forms.module.css' - -const isValidUrl = (str) => { - try { - new URL(str) - } catch (_) { - return false - } - - return true -} - -const CreateEntryForm = ({ onSubmit, entries, ...props }) => { - const [url, setUrl] = useState(props.url || '') - const [title, setTitle] = useState(props.title || '') - const [description, setDescription] = useState(props.description || '') - const [duration, setDuration] = useState(props.duration || '3') - const [interval, setWaitInterval] = useState(props.interval || INTERVALS.DAYS) - const [error, setError] = useState('') - - const urlInputRef = useRef(null) - - useEffect(() => { - urlInputRef.current.focus() - }, []) - - const handleSubmit = useCallback(() => { - if (!url || !title) { - setError('URL and Title are required.') - } else if ( - !props.id && - entries.map((x) => new URL(x.url).href).includes(new URL(url).href) - ) { - setError('URL already exists.') - } else if (!isValidUrl(url)) { - setError('URL is invalid.') - } else if (!duration) { - setError('Duration is required.') - } else if (duration < 1) { - setError('Duration must be greater than 0.') - } else { - setError('') - - const now = new Date() - - const entry = { - url: new URL(url).href, - title, - description, - duration, - interval, - visited: 0, - id: props.id || uuidv4(), - createdAt: props.createdAt || now.toISOString(), - updatedAt: props.createdAt ? now.toISOString() : null, - dismissedAt: props.dismissedAt || null, - } - - onSubmit(entry) - - setUrl('') - setTitle('') - setDescription('') - setDuration(3) - setWaitInterval(INTERVALS.DAYS) - } - }, [ - entries, - description, - duration, - interval, - title, - url, - onSubmit, - props.id, - props.createdAt, - props.dismissedAt, - ]) - - useEffect(() => { - const handleKeydown = ({ keyCode }) => { - if (keyCode === KEY_CODES.ENTER) { - handleSubmit() - } - } - - document.addEventListener('keydown', handleKeydown) - - return () => document.removeEventListener('keydown', handleKeydown) - }, [handleSubmit]) - - return ( -
- {!!error &&
{error}
} - - - - - onChange(x.currentTarget.value)} - {...props} - /> - - ) -}) - -Input.propTypes = { - type: PropTypes.string.isRequired, - label: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - onChange: PropTypes.func.isRequired, -} - -Input.defaultProps = { - type: 'text', -} - -const Button = (props) => { - const ref = useRef() - const { buttonProps } = useButton(props, ref) - const { children } = props - - return ( -
- -
- ) -} - -Button.propTypes = { - children: PropTypes.node.isRequired, -} - -Button.defaultProps = { - children: 'Submit', -} - -const Select = ({ label, value, options, onChange }) => { - return ( -
- {!!label && } - -
- ) -} - -Select.propTypes = { - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - options: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - display: PropTypes.string.isRequired, - }) - ).isRequired, - label: PropTypes.string, - onChange: PropTypes.func.isRequired, -} - -const Textarea = ({ label, value, onChange }) => { - return ( -
- {!!label && } -