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 (
+
+
+
+ )
+}
+
+// === 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 (
+
+
+
+ )
+}
+
+// === GitHub Panel ===
+
+const GitHubPanel = () => {
+ const {
+ listGithubAccounts, createGithubAccount, updateGithubAccount, deleteGithubAccount,
+ addGithubRepo, updateGithubRepo, deleteGithubRepo, syncGithubRepo
+ } = useSources()
+ const [accounts, setAccounts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [showAccountForm, setShowAccountForm] = useState(false)
+ const [editingAccount, setEditingAccount] = useState(null)
+ const [addingRepoTo, setAddingRepoTo] = useState(null)
+
+ const loadAccounts = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await listGithubAccounts()
+ setAccounts(data)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to load accounts')
+ } finally {
+ setLoading(false)
+ }
+ }, [listGithubAccounts])
+
+ useEffect(() => { loadAccounts() }, [loadAccounts])
+
+ const handleCreateAccount = async (data: any) => {
+ await createGithubAccount(data)
+ setShowAccountForm(false)
+ loadAccounts()
+ }
+
+ const handleUpdateAccount = async (data: any) => {
+ if (editingAccount) {
+ await updateGithubAccount(editingAccount.id, data)
+ setEditingAccount(null)
+ loadAccounts()
+ }
+ }
+
+ const handleDeleteAccount = async (id: number) => {
+ await deleteGithubAccount(id)
+ loadAccounts()
+ }
+
+ const handleToggleActive = async (account: GithubAccount) => {
+ await updateGithubAccount(account.id, { active: !account.active })
+ loadAccounts()
+ }
+
+ const handleAddRepo = async (accountId: number, data: any) => {
+ await addGithubRepo(accountId, data)
+ setAddingRepoTo(null)
+ loadAccounts()
+ }
+
+ const handleDeleteRepo = async (accountId: number, repoId: number) => {
+ await deleteGithubRepo(accountId, repoId)
+ loadAccounts()
+ }
+
+ const handleToggleRepoActive = async (accountId: number, repoId: number, active: boolean) => {
+ await updateGithubRepo(accountId, repoId, { active: !active })
+ loadAccounts()
+ }
+
+ const handleSyncRepo = async (accountId: number, repoId: number) => {
+ await syncGithubRepo(accountId, repoId)
+ loadAccounts()
+ }
+
+ if (loading) return
+ if (error) return
+
+ return (
+
+
+
GitHub Accounts
+
+
+
+ {accounts.length === 0 ? (
+
setShowAccountForm(true)}
+ />
+ ) : (
+
+ {accounts.map(account => (
+
+
+
+
{account.name}
+
+ {account.auth_type === 'pat' ? 'Personal Access Token' : 'GitHub App'}
+
+
+
+ handleToggleActive(account)} />
+
+
+
+
+
+
+
+
Tracked Repositories ({account.repos.length})
+
+
+
+ {account.repos.length === 0 ? (
+
No repositories tracked
+ ) : (
+
+ {account.repos.map(repo => (
+
+
+ {repo.repo_path}
+
+
+
+ {repo.track_issues && Issues}
+ {repo.track_prs && PRs}
+ {repo.track_comments && Comments}
+
+
+ handleSyncRepo(account.id, repo.id)}
+ disabled={!repo.active || !account.active}
+ label="Sync"
+ />
+
+
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {showAccountForm && (
+ setShowAccountForm(false)}
+ />
+ )}
+
+ {editingAccount && (
+ setEditingAccount(null)}
+ />
+ )}
+
+ {addingRepoTo && (
+ handleAddRepo(addingRepoTo, data)}
+ onCancel={() => setAddingRepoTo(null)}
+ />
+ )}
+
+ )
+}
+
+interface GitHubAccountFormProps {
+ account?: GithubAccount
+ onSubmit: (data: any) => Promise
+ onCancel: () => void
+}
+
+const GitHubAccountForm = ({ account, onSubmit, onCancel }: GitHubAccountFormProps) => {
+ const [formData, setFormData] = useState({
+ name: account?.name || '',
+ auth_type: account?.auth_type || 'pat',
+ access_token: '',
+ app_id: account?.app_id || undefined,
+ installation_id: account?.installation_id || undefined,
+ private_key: '',
+ })
+ 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: any = { name: formData.name, auth_type: formData.auth_type }
+ if (formData.auth_type === 'pat') {
+ if (formData.access_token) data.access_token = formData.access_token
+ } else {
+ if (formData.app_id) data.app_id = formData.app_id
+ if (formData.installation_id) data.installation_id = formData.installation_id
+ if (formData.private_key) data.private_key = formData.private_key
+ }
+ await onSubmit(data)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to save')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+interface GitHubRepoFormProps {
+ accountId: number
+ onSubmit: (data: any) => Promise
+ onCancel: () => void
+}
+
+const GitHubRepoForm = ({ accountId, onSubmit, onCancel }: GitHubRepoFormProps) => {
+ const [formData, setFormData] = useState({
+ owner: '',
+ name: '',
+ track_issues: true,
+ track_prs: true,
+ track_comments: true,
+ track_project_fields: false,
+ tags: [] as string[],
+ check_interval: 60,
+ })
+ 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 (
+
+
+
+ )
+}
+
+// === Google Drive Panel ===
+
+const GoogleDrivePanel = () => {
+ const {
+ listGoogleAccounts, getGoogleAuthUrl, deleteGoogleAccount,
+ addGoogleFolder, updateGoogleFolder, deleteGoogleFolder, syncGoogleFolder,
+ getGoogleOAuthConfig, uploadGoogleOAuthConfig, deleteGoogleOAuthConfig
+ } = useSources()
+ const [accounts, setAccounts] = useState([])
+ const [oauthConfig, setOauthConfig] = useState(undefined)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [addingFolderTo, setAddingFolderTo] = useState(null)
+ const [browsingFoldersFor, setBrowsingFoldersFor] = useState(null)
+ const [uploadingConfig, setUploadingConfig] = useState(false)
+
+ const loadData = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [accountsData, configData] = await Promise.all([
+ listGoogleAccounts(),
+ getGoogleOAuthConfig()
+ ])
+ setAccounts(accountsData)
+ setOauthConfig(configData)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to load data')
+ } finally {
+ setLoading(false)
+ }
+ }, [listGoogleAccounts, getGoogleOAuthConfig])
+
+ useEffect(() => { loadData() }, [loadData])
+
+ const handleConfigUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ setUploadingConfig(true)
+ setError(null)
+ try {
+ const config = await uploadGoogleOAuthConfig(file)
+ setOauthConfig(config)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to upload config')
+ } finally {
+ setUploadingConfig(false)
+ }
+ }
+
+ const handleDeleteConfig = async () => {
+ try {
+ await deleteGoogleOAuthConfig()
+ setOauthConfig(null)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to delete config')
+ }
+ }
+
+ const handleConnect = async () => {
+ try {
+ const { authorization_url } = await getGoogleAuthUrl()
+ window.open(authorization_url, '_blank', 'width=600,height=700')
+ // Poll for new accounts
+ const interval = setInterval(async () => {
+ const newAccounts = await listGoogleAccounts()
+ if (newAccounts.length > accounts.length) {
+ setAccounts(newAccounts)
+ clearInterval(interval)
+ }
+ }, 2000)
+ setTimeout(() => clearInterval(interval), 60000)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to get Google auth URL')
+ }
+ }
+
+ const handleDeleteAccount = async (id: number) => {
+ await deleteGoogleAccount(id)
+ loadData()
+ }
+
+ const handleAddFolder = async (accountId: number, data: any) => {
+ await addGoogleFolder(accountId, data)
+ setAddingFolderTo(null)
+ loadData()
+ }
+
+ const handleBrowseSelect = async (accountId: number, items: SelectedItem[]) => {
+ // Add each selected item as a folder/file
+ for (const item of items) {
+ await addGoogleFolder(accountId, {
+ folder_id: item.id,
+ folder_name: item.name,
+ recursive: item.is_folder ? item.recursive : false,
+ })
+ }
+ setBrowsingFoldersFor(null)
+ loadData()
+ }
+
+ const handleDeleteFolder = async (accountId: number, folderId: number) => {
+ await deleteGoogleFolder(accountId, folderId)
+ loadData()
+ }
+
+ const handleToggleFolderActive = async (accountId: number, folderId: number, active: boolean) => {
+ await updateGoogleFolder(accountId, folderId, { active: !active })
+ loadData()
+ }
+
+ const handleSyncFolder = async (accountId: number, folderId: number) => {
+ await syncGoogleFolder(accountId, folderId)
+ loadData()
+ }
+
+ if (loading) return
+ if (error) return
+
+ // Show OAuth config upload if not configured
+ if (oauthConfig === null) {
+ return (
+
+
+
Google Drive
+
+
+
OAuth Configuration Required
+
Upload your Google OAuth credentials JSON file to enable Google Drive integration.
+
Get this from the Google Cloud Console under APIs & Services → Credentials.
+
+
+
+
+
+ )
+ }
+
+ // Show OAuth config info section
+ const OAuthConfigSection = () => (
+
+
+ OAuth Configuration
+
+
Project: {oauthConfig.project_id}
+
Client ID: {oauthConfig.client_id.substring(0, 20)}...
+
Redirect URIs:
+
+ {oauthConfig.redirect_uris.map((uri, i) => (
+ - {uri}
+ ))}
+
+
+
+
+
+
+
+
+ )
+
+ return (
+
+
+
Google Drive Accounts
+
+
+
+
+
+ {accounts.length === 0 ? (
+
+ ) : (
+
+ {accounts.map(account => (
+
+
+
+
{account.name}
+
{account.email}
+
+
+
+
+
+
+
+ {account.sync_error && (
+
{account.sync_error}
+ )}
+
+
+
+
Synced Folders ({account.folders.length})
+
+
+
+
+
+
+ {account.folders.length === 0 ? (
+
No folders configured for sync
+ ) : (
+
+ {account.folders.map(folder => (
+
+
+
+ {folder.recursive && Recursive}
+ {folder.include_shared && Shared}
+
+
+ handleSyncFolder(account.id, folder.id)}
+ disabled={!folder.active || !account.active}
+ label="Sync"
+ />
+
+
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {addingFolderTo && (
+
handleAddFolder(addingFolderTo, data)}
+ onCancel={() => setAddingFolderTo(null)}
+ />
+ )}
+
+ {browsingFoldersFor && (
+ handleBrowseSelect(browsingFoldersFor, items)}
+ onCancel={() => setBrowsingFoldersFor(null)}
+ />
+ )}
+
+ )
+}
+
+// === Google Drive Folder Browser ===
+
+interface PathItem {
+ id: string
+ name: string
+}
+
+interface SelectedItem {
+ id: string
+ name: string
+ is_folder: boolean
+ recursive: boolean
+}
+
+interface FolderBrowserProps {
+ accountId: number
+ onSelect: (items: SelectedItem[]) => void
+ onCancel: () => void
+}
+
+const FolderBrowser = ({ accountId, onSelect, onCancel }: FolderBrowserProps) => {
+ const { browseGoogleDrive } = useSources()
+ const [path, setPath] = useState([{ id: 'root', name: 'My Drive' }])
+ const [items, setItems] = useState([])
+ const [selected, setSelected] = useState