diff --git a/db/migrations/versions/20251229_120000_add_google_drive.py b/db/migrations/versions/20251229_120000_add_google_drive.py new file mode 100644 index 0000000..c9f288f --- /dev/null +++ b/db/migrations/versions/20251229_120000_add_google_drive.py @@ -0,0 +1,187 @@ +"""Add Google Drive integration tables + +Revision ID: e1f2a3b4c5d6 +Revises: d0e1f2a3b4c5 +Create Date: 2025-12-29 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "e1f2a3b4c5d6" +down_revision: Union[str, None] = "d0e1f2a3b4c5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create google_oauth_config table (for storing OAuth credentials) + op.create_table( + "google_oauth_config", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("client_id", sa.Text(), nullable=False), + sa.Column("client_secret", sa.Text(), nullable=False), + sa.Column("project_id", sa.Text(), nullable=True), + sa.Column( + "auth_uri", + sa.Text(), + server_default="https://accounts.google.com/o/oauth2/auth", + nullable=False, + ), + sa.Column( + "token_uri", + sa.Text(), + server_default="https://oauth2.googleapis.com/token", + nullable=False, + ), + sa.Column( + "redirect_uris", sa.ARRAY(sa.Text()), server_default="{}", nullable=False + ), + sa.Column( + "javascript_origins", sa.ARRAY(sa.Text()), server_default="{}", nullable=False + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + # Create google_accounts table + op.create_table( + "google_accounts", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("email", sa.Text(), nullable=False), + sa.Column("access_token", sa.Text(), nullable=True), + sa.Column("refresh_token", sa.Text(), nullable=True), + sa.Column("token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("scopes", sa.ARRAY(sa.Text()), server_default="{}", nullable=False), + sa.Column("active", sa.Boolean(), server_default="true", nullable=False), + sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("sync_error", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_index( + "google_accounts_active_idx", "google_accounts", ["active", "last_sync_at"] + ) + op.create_index("google_accounts_email_idx", "google_accounts", ["email"]) + + # Create google_folders table + op.create_table( + "google_folders", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("account_id", sa.BigInteger(), nullable=False), + sa.Column("folder_id", sa.Text(), nullable=False), + sa.Column("folder_name", sa.Text(), nullable=False), + sa.Column("folder_path", sa.Text(), nullable=True), + sa.Column("recursive", sa.Boolean(), server_default="true", nullable=False), + sa.Column( + "include_shared", sa.Boolean(), server_default="false", nullable=False + ), + sa.Column( + "mime_type_filter", sa.ARRAY(sa.Text()), server_default="{}", nullable=False + ), + sa.Column("tags", sa.ARRAY(sa.Text()), server_default="{}", nullable=False), + sa.Column("check_interval", sa.Integer(), server_default="60", nullable=False), + sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("active", sa.Boolean(), server_default="true", nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["account_id"], ["google_accounts.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "account_id", "folder_id", name="unique_folder_per_account" + ), + ) + op.create_index( + "google_folders_active_idx", "google_folders", ["active", "last_sync_at"] + ) + + # Create google_doc table (inherits from source_item) + op.create_table( + "google_doc", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("google_file_id", sa.Text(), nullable=False), + sa.Column("google_modified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("original_mime_type", sa.Text(), nullable=True), + sa.Column("folder_id", sa.BigInteger(), nullable=True), + sa.Column("folder_path", sa.Text(), nullable=True), + sa.Column("owner", sa.Text(), nullable=True), + sa.Column("last_modified_by", sa.Text(), nullable=True), + sa.Column("word_count", sa.Integer(), nullable=True), + sa.Column("content_hash", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["id"], ["source_item.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["folder_id"], ["google_folders.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "google_doc_file_id_idx", "google_doc", ["google_file_id"], unique=True + ) + op.create_index("google_doc_folder_idx", "google_doc", ["folder_id"]) + op.create_index("google_doc_modified_idx", "google_doc", ["google_modified_at"]) + op.create_index("google_doc_title_idx", "google_doc", ["title"]) + + +def downgrade() -> None: + # Drop google_doc table + op.drop_index("google_doc_title_idx", table_name="google_doc") + op.drop_index("google_doc_modified_idx", table_name="google_doc") + op.drop_index("google_doc_folder_idx", table_name="google_doc") + op.drop_index("google_doc_file_id_idx", table_name="google_doc") + op.drop_table("google_doc") + + # Drop google_folders table + op.drop_index("google_folders_active_idx", table_name="google_folders") + op.drop_table("google_folders") + + # Drop google_accounts table + op.drop_index("google_accounts_email_idx", table_name="google_accounts") + op.drop_index("google_accounts_active_idx", table_name="google_accounts") + op.drop_table("google_accounts") + + # Drop google_oauth_config table + op.drop_table("google_oauth_config") diff --git a/frontend/src/App.css b/frontend/src/App.css index bf027d9..64f0dc0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -959,4 +959,980 @@ body { align-items: stretch; gap: 0.5rem; } +} + +/* ============================================ + Sources Management Styles + ============================================ */ + +.sources-view { + min-height: 100vh; + background: #f8fafc; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.sources-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e2e8f0; +} + +.sources-header h2 { + color: #2d3748; + font-size: 1.8rem; + font-weight: 600; +} + +/* Tabs */ +.sources-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + border-bottom: 2px solid #e2e8f0; + padding-bottom: 0; +} + +.sources-tabs .tab { + background: none; + border: none; + padding: 0.75rem 1.5rem; + font-size: 1rem; + color: #718096; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all 0.2s; +} + +.sources-tabs .tab:hover { + color: #4a5568; +} + +.sources-tabs .tab.active { + color: #667eea; + border-bottom-color: #667eea; + font-weight: 500; +} + +/* Source Panel */ +.source-panel { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.panel-header h3 { + color: #2d3748; + font-size: 1.2rem; + font-weight: 600; +} + +/* Source List */ +.source-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Source Card */ +.source-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + transition: border-color 0.2s; +} + +.source-card:hover { + border-color: #cbd5e0; +} + +.source-card.inactive { + opacity: 0.7; + background: #f1f5f9; +} + +.source-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.source-card-info h4 { + color: #2d3748; + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.source-subtitle { + color: #718096; + font-size: 0.85rem; +} + +.source-details { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0.5rem 0; + font-size: 0.85rem; + color: #4a5568; +} + +.source-description { + color: #4a5568; + font-size: 0.9rem; + margin: 0.5rem 0; +} + +.source-card-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid #e2e8f0; +} + +.source-card-actions-inline { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Status Badge */ +.status-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.status-badge.active { + background: #c6f6d5; + color: #276749; +} + +.status-badge.inactive { + background: #fed7d7; + color: #c53030; +} + +/* Sync Status */ +.sync-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: #718096; +} + +.sync-error { + color: #c53030; + font-weight: 500; +} + +/* Buttons */ +.add-btn { + background: #667eea; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.add-btn:hover { + background: #5a67d8; +} + +.add-btn.small { + padding: 0.25rem 0.75rem; + font-size: 0.8rem; +} + +.sync-btn { + background: #48bb78; + color: white; + border: none; + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.sync-btn:hover:not(:disabled) { + background: #38a169; +} + +.sync-btn:disabled { + background: #a0aec0; + cursor: not-allowed; +} + +.edit-btn { + background: #edf2f7; + color: #4a5568; + border: 1px solid #e2e8f0; + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; +} + +.edit-btn:hover { + background: #e2e8f0; +} + +.delete-btn { + background: #fff5f5; + color: #c53030; + border: 1px solid #fed7d7; + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; +} + +.delete-btn:hover { + background: #fed7d7; +} + +.delete-btn.small { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.toggle-btn { + background: #edf2f7; + color: #4a5568; + border: 1px solid #e2e8f0; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; +} + +.toggle-btn:hover { + background: #e2e8f0; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem; + color: #718096; +} + +.empty-state p { + margin-bottom: 1rem; +} + +/* Loading State */ +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: #718096; +} + +.loading-state .loading-spinner { + margin-bottom: 1rem; +} + +/* Error State */ +.error-state { + text-align: center; + padding: 2rem; + color: #c53030; + background: #fff5f5; + border-radius: 8px; +} + +.retry-btn { + background: #667eea; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + margin-top: 1rem; + cursor: pointer; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: white; + border-radius: 12px; + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e2e8f0; +} + +.modal-header h3 { + color: #2d3748; + font-size: 1.1rem; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + color: #718096; + cursor: pointer; + line-height: 1; +} + +.modal-close:hover { + color: #4a5568; +} + +.modal-content { + padding: 1.5rem; +} + +/* Form Styles */ +.source-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.form-group label { + font-size: 0.9rem; + font-weight: 500; + color: #4a5568; +} + +.form-group input, +.form-group select, +.form-group textarea { + padding: 0.5rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.9rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); +} + +.form-group.checkbox label { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.form-group.checkboxes { + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.9rem; + color: #4a5568; + cursor: pointer; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-hint { + font-size: 0.75rem; + color: #718096; + margin-top: 0.25rem; +} + +.form-error { + background: #fff5f5; + color: #c53030; + padding: 0.75rem; + border-radius: 6px; + font-size: 0.9rem; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.cancel-btn { + background: #edf2f7; + color: #4a5568; + border: 1px solid #e2e8f0; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; +} + +.cancel-btn:hover { + background: #e2e8f0; +} + +.submit-btn { + background: #667eea; + color: white; + border: none; + padding: 0.5rem 1.5rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; +} + +.submit-btn:hover:not(:disabled) { + background: #5a67d8; +} + +.submit-btn:disabled { + background: #a0aec0; + cursor: not-allowed; +} + +/* Tags Input */ +.tags-input { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.tags-input .tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: #edf2f7; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; +} + +.tags-input .tag button { + background: none; + border: none; + color: #718096; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0; +} + +.tags-input .tag button:hover { + color: #c53030; +} + +.tag-input { + padding: 0.375rem 0.5rem; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 0.85rem; +} + +/* Interval Input */ +.interval-input { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.interval-input label { + font-size: 0.9rem; + font-weight: 500; + color: #4a5568; +} + +.interval-controls { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.interval-controls input { + width: 60px; + padding: 0.375rem 0.5rem; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 0.9rem; + text-align: center; +} + +.interval-controls span { + color: #718096; + font-size: 0.85rem; +} + +/* Confirm Dialog */ +.confirm-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; +} + +.confirm-dialog { + background: white; + padding: 1.5rem; + border-radius: 8px; + max-width: 400px; + text-align: center; +} + +.confirm-dialog p { + margin-bottom: 1rem; + color: #4a5568; +} + +.confirm-dialog-buttons { + display: flex; + justify-content: center; + gap: 0.75rem; +} + +.confirm-btn { + background: #e53e3e; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; +} + +.confirm-btn:hover { + background: #c53030; +} + +/* GitHub Account Card */ +.github-account-card, +.google-account-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; +} + +.repos-section, +.folders-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.repos-header, +.folders-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.repos-header h5, +.folders-header h5 { + font-size: 0.9rem; + color: #4a5568; + font-weight: 500; +} + +.repos-list, +.folders-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.repo-card, +.folder-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; +} + +.repo-card.inactive, +.folder-card.inactive { + opacity: 0.6; +} + +.repo-info, +.folder-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.repo-path { + font-weight: 500; + color: #2d3748; +} + +.folder-name { + font-weight: 500; + color: #2d3748; + text-decoration: none; +} + +a.folder-name:hover { + color: #667eea; + text-decoration: underline; +} + +.folder-path { + font-size: 0.8rem; + color: #718096; +} + +.repo-tracking, +.folder-settings { + display: flex; + gap: 0.375rem; +} + +.tracking-badge, +.setting-badge { + background: #e2e8f0; + color: #4a5568; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.7rem; +} + +.repo-actions, +.folder-actions { + display: flex; + gap: 0.375rem; +} + +.no-repos, +.no-folders { + color: #718096; + font-size: 0.9rem; + font-style: italic; +} + +.sync-error-banner { + background: #fff5f5; + color: #c53030; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + margin: 0.5rem 0; +} + +/* Responsive */ +@media (max-width: 768px) { + .sources-view { + padding: 1rem; + } + + .sources-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .sources-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .sources-tabs .tab { + padding: 0.5rem 1rem; + white-space: nowrap; + } + + .panel-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .source-card-header { + flex-direction: column; + gap: 0.5rem; + } + + .source-card-actions { + flex-wrap: wrap; + } + + .form-row { + grid-template-columns: 1fr; + } + + .repo-card, + .folder-card { + flex-direction: column; + align-items: flex-start; + } + + .repo-actions, + .folder-actions { + width: 100%; + justify-content: flex-start; + } +} + +/* === Folder Browser === */ + +.folder-browser { + display: flex; + flex-direction: column; + min-height: 400px; + max-height: 60vh; +} + +.folder-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.75rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 0.875rem; +} + +.breadcrumb-sep { + color: #a0aec0; + margin: 0 0.25rem; +} + +.breadcrumb-item { + background: none; + border: none; + padding: 0.25rem 0.5rem; + border-radius: 4px; + color: #667eea; + cursor: pointer; + font-size: 0.875rem; +} + +.breadcrumb-item:hover:not(:disabled) { + background: #edf2f7; +} + +.breadcrumb-item.current { + color: #2d3748; + font-weight: 500; + cursor: default; +} + +.folder-loading, +.folder-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: #718096; + font-size: 0.875rem; + padding: 2rem; +} + +.folder-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.folder-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + transition: background 0.15s; +} + +.folder-item:hover { + background: #f7fafc; +} + +.folder-item.selected { + background: #ebf8ff; +} + +.folder-item-checkbox { + display: flex; + align-items: center; +} + +.folder-item-checkbox input { + width: 16px; + height: 16px; + cursor: pointer; +} + +.folder-item-icon { + font-size: 1.25rem; + width: 24px; + text-align: center; +} + +.folder-item-name { + flex: 1; + font-size: 0.875rem; + color: #2d3748; + text-decoration: none; +} + +a.folder-item-name { + color: #4a5568; +} + +a.folder-item-name:hover { + color: #667eea; + text-decoration: underline; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-item-recursive { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: #718096; + margin-left: auto; + padding: 0.25rem 0.5rem; + background: #f7fafc; + border-radius: 4px; +} + +.folder-item-recursive input { + width: 14px; + height: 14px; +} + +.folder-item-enter { + background: none; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 0.25rem 0.5rem; + color: #667eea; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.15s; +} + +.folder-item-enter:hover { + background: #667eea; + color: white; + border-color: #667eea; +} + +.folder-browser-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +.selected-count { + font-size: 0.875rem; + color: #718096; +} + +.folder-browser-actions { + display: flex; + gap: 0.5rem; +} + +.folders-actions { + display: flex; + gap: 0.5rem; +} + +.add-btn.secondary { + background: #f7fafc; + color: #4a5568; + border: 1px solid #e2e8f0; +} + +.add-btn.secondary:hover { + background: #edf2f7; } \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3b28c2b..4d9dee8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,7 +4,7 @@ import './App.css' import { useAuth } from '@/hooks/useAuth' import { useOAuth } from '@/hooks/useOAuth' -import { Loading, LoginPrompt, AuthError, Dashboard, Search } from '@/components' +import { Loading, LoginPrompt, AuthError, Dashboard, Search, Sources } from '@/components' // AuthWrapper handles redirects based on auth state const AuthWrapper = () => { @@ -94,6 +94,14 @@ const AuthWrapper = () => { ) } /> + + ) : ( + + ) + } /> + {/* Default redirect */} diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 6a7d938..2856859 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -26,6 +26,11 @@ const Dashboard = ({ onLogout }) => {

Search through your knowledge base

+ +

Sources

+

Manage email, GitHub, RSS feeds, and Google Drive

+ +
console.log(await listNotes())}>

📝 Notes

Create and manage your notes

diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index 546fb01..eaf565a 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -1,5 +1,6 @@ export { default as Loading } from './Loading' export { default as Dashboard } from './Dashboard' export { default as Search } from './search' +export { default as Sources } from './sources' export { default as LoginPrompt } from './auth/LoginPrompt' export { default as AuthError } from './auth/AuthError' \ No newline at end of file diff --git a/frontend/src/components/sources/Sources.tsx b/frontend/src/components/sources/Sources.tsx new file mode 100644 index 0000000..78c0230 --- /dev/null +++ b/frontend/src/components/sources/Sources.tsx @@ -0,0 +1,1528 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { useSources, EmailAccount, ArticleFeed, GithubAccount, GoogleAccount, GoogleOAuthConfig, DriveItem, BrowseResponse, GoogleFolderCreate } from '@/hooks/useSources' +import { + SourceCard, + Modal, + TagsInput, + IntervalInput, + EmptyState, + LoadingState, + ErrorState, + SyncButton, + StatusBadge, + SyncStatus, + ConfirmDialog, +} from './shared' + +type TabType = 'email' | 'feeds' | 'github' | 'google' + +const Sources = () => { + const [activeTab, setActiveTab] = useState('email') + + return ( +
+
+ Back +

Manage Sources

+
+ +
+ + + + +
+ +
+ {activeTab === 'email' && } + {activeTab === 'feeds' && } + {activeTab === 'github' && } + {activeTab === 'google' && } +
+
+ ) +} + +// === Email Panel === + +const EmailPanel = () => { + const { listEmailAccounts, createEmailAccount, updateEmailAccount, deleteEmailAccount, syncEmailAccount, testEmailAccount } = useSources() + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [editingAccount, setEditingAccount] = useState(null) + + const loadAccounts = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await listEmailAccounts() + setAccounts(data) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load accounts') + } finally { + setLoading(false) + } + }, [listEmailAccounts]) + + useEffect(() => { loadAccounts() }, [loadAccounts]) + + const handleCreate = async (data: any) => { + await createEmailAccount(data) + setShowForm(false) + loadAccounts() + } + + const handleUpdate = async (data: any) => { + if (editingAccount) { + await updateEmailAccount(editingAccount.id, data) + setEditingAccount(null) + loadAccounts() + } + } + + const handleDelete = async (id: number) => { + await deleteEmailAccount(id) + loadAccounts() + } + + const handleToggleActive = async (account: EmailAccount) => { + await updateEmailAccount(account.id, { active: !account.active }) + loadAccounts() + } + + const handleSync = async (id: number) => { + await syncEmailAccount(id) + loadAccounts() + } + + if (loading) return + if (error) return + + return ( +
+
+

Email Accounts

+ +
+ + {accounts.length === 0 ? ( + setShowForm(true)} + /> + ) : ( +
+ {accounts.map(account => ( + handleToggleActive(account)} + onEdit={() => setEditingAccount(account)} + onDelete={() => handleDelete(account.id)} + onSync={() => handleSync(account.id)} + > +
+ Server: {account.imap_server}:{account.imap_port} + {account.folders.length > 0 && ( + Folders: {account.folders.join(', ')} + )} +
+
+ ))} +
+ )} + + {showForm && ( + setShowForm(false)} + /> + )} + + {editingAccount && ( + setEditingAccount(null)} + /> + )} +
+ ) +} + +interface EmailFormProps { + account?: EmailAccount + onSubmit: (data: any) => Promise + onCancel: () => void +} + +const EmailForm = ({ account, onSubmit, onCancel }: EmailFormProps) => { + const [formData, setFormData] = useState({ + name: account?.name || '', + email_address: account?.email_address || '', + imap_server: account?.imap_server || '', + imap_port: account?.imap_port || 993, + username: account?.username || '', + password: '', + use_ssl: account?.use_ssl ?? true, + folders: account?.folders || [], + tags: account?.tags || [], + }) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + setError(null) + try { + const data = { ...formData } + if (account && !data.password) { + delete (data as any).password + } + await onSubmit(data) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save') + } finally { + setSubmitting(false) + } + } + + return ( + +
+ {error &&
{error}
} + +
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, email_address: e.target.value })} + required + disabled={!!account} + /> +
+ +
+
+ + setFormData({ ...formData, imap_server: e.target.value })} + required + placeholder="imap.gmail.com" + /> +
+
+ + setFormData({ ...formData, imap_port: parseInt(e.target.value) })} + required + /> +
+
+ +
+ + setFormData({ ...formData, username: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + required={!account} + /> +
+ +
+ +
+ +
+ + setFormData({ ...formData, tags })} + /> +
+ +
+ + +
+
+
+ ) +} + +// === Feeds Panel === + +const FeedsPanel = () => { + const { listArticleFeeds, createArticleFeed, updateArticleFeed, deleteArticleFeed, syncArticleFeed } = useSources() + const [feeds, setFeeds] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [editingFeed, setEditingFeed] = useState(null) + + const loadFeeds = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await listArticleFeeds() + setFeeds(data) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load feeds') + } finally { + setLoading(false) + } + }, [listArticleFeeds]) + + useEffect(() => { loadFeeds() }, [loadFeeds]) + + const handleCreate = async (data: any) => { + await createArticleFeed(data) + setShowForm(false) + loadFeeds() + } + + const handleUpdate = async (data: any) => { + if (editingFeed) { + await updateArticleFeed(editingFeed.id, data) + setEditingFeed(null) + loadFeeds() + } + } + + const handleDelete = async (id: number) => { + await deleteArticleFeed(id) + loadFeeds() + } + + const handleToggleActive = async (feed: ArticleFeed) => { + await updateArticleFeed(feed.id, { active: !feed.active }) + loadFeeds() + } + + const handleSync = async (id: number) => { + await syncArticleFeed(id) + loadFeeds() + } + + if (loading) return + if (error) return + + return ( +
+
+

RSS Feeds

+ +
+ + {feeds.length === 0 ? ( + setShowForm(true)} + /> + ) : ( +
+ {feeds.map(feed => ( + handleToggleActive(feed)} + onEdit={() => setEditingFeed(feed)} + onDelete={() => handleDelete(feed.id)} + onSync={() => handleSync(feed.id)} + > + {feed.description && ( +

{feed.description}

+ )} +
+ ))} +
+ )} + + {showForm && ( + setShowForm(false)} + /> + )} + + {editingFeed && ( + setEditingFeed(null)} + /> + )} +
+ ) +} + +interface FeedFormProps { + feed?: ArticleFeed + onSubmit: (data: any) => Promise + onCancel: () => void +} + +const FeedForm = ({ feed, onSubmit, onCancel }: FeedFormProps) => { + const [formData, setFormData] = useState({ + url: feed?.url || '', + title: feed?.title || '', + description: feed?.description || '', + tags: feed?.tags || [], + check_interval: feed?.check_interval || 1440, + active: feed?.active ?? true, + }) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + setError(null) + try { + await onSubmit(formData) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save') + } finally { + setSubmitting(false) + } + } + + return ( + +
+ {error &&
{error}
} + +
+ + setFormData({ ...formData, url: e.target.value })} + required + disabled={!!feed} + placeholder="https://example.com/feed.xml" + /> +
+ +
+ + setFormData({ ...formData, title: e.target.value })} + /> +
+ +
+ +