Initial Commit
This commit is contained in:
0
frontend_react/.Rhistory
Normal file
0
frontend_react/.Rhistory
Normal file
24
frontend_react/.gitignore
vendored
Normal file
24
frontend_react/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
frontend_react/README.md
Normal file
12
frontend_react/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
33
frontend_react/eslint.config.js
Normal file
33
frontend_react/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
frontend_react/index.html
Normal file
13
frontend_react/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2825
frontend_react/package-lock.json
generated
Normal file
2825
frontend_react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend_react/package.json
Normal file
31
frontend_react/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "surfsmart_react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
1
frontend_react/public/vite.svg
Normal file
1
frontend_react/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
90
frontend_react/src/App.jsx
Normal file
90
frontend_react/src/App.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// frontend/src/App.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import LeftSidebar from './components/LeftSidebar/LeftSidebar.jsx';
|
||||
import MainContent from './components/MainContent/MainContent.jsx';
|
||||
import LoginPage from './components/LoginPage/LoginPage.jsx';
|
||||
import styles from './App.module.css';
|
||||
import { getAuthToken } from './services/api'; // Assuming fetchProjects is not needed here directly
|
||||
|
||||
/**
|
||||
* App Component
|
||||
*
|
||||
* Manages authentication state AND the currently selected project ID.
|
||||
* Renders either LoginPage or the main layout.
|
||||
*/
|
||||
function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
// --- State for selected project ID ---
|
||||
const [currentProjectId, setCurrentProjectId] = useState(null); // Initialize to null
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
setIsLoggedIn(true);
|
||||
// If logged in, we might want to fetch projects and set an initial ID,
|
||||
// but that logic is currently in LeftSidebar. We'll let LeftSidebar
|
||||
// trigger the initial selection via the callback for now.
|
||||
} else {
|
||||
// Ensure currentProjectId is reset if no token found
|
||||
setCurrentProjectId(null);
|
||||
}
|
||||
setAuthChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsLoggedIn(true);
|
||||
// Reset project ID on new login, let LeftSidebar set the initial one
|
||||
setCurrentProjectId(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
setIsLoggedIn(false);
|
||||
setCurrentProjectId(null); // Reset project ID on logout
|
||||
console.log('User logged out.');
|
||||
};
|
||||
|
||||
// --- Handler function to be passed to LeftSidebar ---
|
||||
const handleProjectSelect = (projectId) => {
|
||||
console.log("App: Project selected:", projectId);
|
||||
setCurrentProjectId(projectId);
|
||||
};
|
||||
// --- End handler ---
|
||||
|
||||
console.log('Render - isLoggedIn state:', isLoggedIn);
|
||||
console.log('Render - currentProjectId state:', currentProjectId);
|
||||
|
||||
|
||||
if (!authChecked) {
|
||||
return <div>Loading Authentication...</div>; // Or a loading spinner
|
||||
}
|
||||
|
||||
const containerClassName = isLoggedIn
|
||||
? `${styles.appContainer} ${styles.loggedInLayout}`
|
||||
: styles.appContainer;
|
||||
|
||||
console.log('Applied className:', containerClassName);
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{/* Pass down currentProjectId and the selection handler */}
|
||||
<LeftSidebar
|
||||
onLogout={handleLogout}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
currentProjectId={currentProjectId} // Pass current ID for highlighting
|
||||
/>
|
||||
{/* Pass down currentProjectId */}
|
||||
<MainContent currentProjectId={currentProjectId} />
|
||||
{/* Blank columns handled by CSS Grid */}
|
||||
</>
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
150
frontend_react/src/App.module.css
Normal file
150
frontend_react/src/App.module.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* App.module.css */
|
||||
|
||||
/* Define reusable variables globally within this module scope or use :global(:root) */
|
||||
:global(:root) {
|
||||
/* Color Palette */
|
||||
--primary-color: #b2e3b6; /* 柔和浅绿色 */
|
||||
--primary-hover-color: #9fd4a6; /* 悬停稍深一点 */
|
||||
--primary-active-color: #89c897; /* 点击时进一步加深 */
|
||||
--secondary-color: #a9b9ac; /* 中性色,偏灰绿 */
|
||||
--secondary-hover-color: #95a89b;
|
||||
--accent-color: #76c28f; /* 强调色,稍饱和 */
|
||||
--accent-hover-color: #5bab74;
|
||||
|
||||
--ai-background: #799fff91;
|
||||
--ai-background-hover: #627cca75;
|
||||
--ai-background-activate: #4063cc7c;
|
||||
--ai-text: #d40000;
|
||||
--ai-text-hover: #7e2525;
|
||||
--ai-text-activate: #641313;
|
||||
|
||||
--success-color: #6fbf73; /* 成功提示,温和的绿 */
|
||||
--danger-color: #dc6b6b; /* 警告/错误保留红色但稍柔和 */
|
||||
--warning-color: #e6c87f; /* 黄色提示柔化处理 */
|
||||
--light-color: #f3f8f4; /* 浅绿色背景,替代全白 */
|
||||
--white-color: #ffffff;
|
||||
--dark-color: #2e3d31; /* 深色,但不纯黑 */
|
||||
--text-color-primary: #1d2b21; /* 主文字色,深灰绿 */
|
||||
--text-color-secondary: #5c6e5f; /* 次文字色,浅灰绿 */
|
||||
--text-color-light: #3a4b3f; /* 用于反白场景下的文字 */
|
||||
|
||||
--border-color: #cbd5cb;
|
||||
--border-radius-sm: 0.25rem;
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
/* Background Arc Colors (协调的透明绿色调) */
|
||||
--arc-color-1: rgba(183, 228, 184, 0.25); /* 轻绿 */
|
||||
--arc-color-2: rgba(169, 209, 174, 0.2); /* 绿灰 */
|
||||
--arc-color-3: rgba(202, 235, 210, 0.3); /* 白绿 */
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: all 0.15s ease-in-out;
|
||||
--transition-base: all 0.2s ease-in-out;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Base styles for the app container */
|
||||
.appContainer {
|
||||
min-height: 100vh;
|
||||
/* background-color: var(--light-color); */ /* Background now handled by ::before */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative; /* Needed for z-index stacking context if ::before uses absolute */
|
||||
z-index: 1; /* Ensure content is above the ::before pseudo-element */
|
||||
}
|
||||
|
||||
/* --- Fixed Background with Arcs using ::before --- */
|
||||
.appContainer::before {
|
||||
content: '';
|
||||
position: fixed; /* Fixed relative to viewport */
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1; /* Place behind the content */
|
||||
background-color: var(--light-color); /* Base background color */
|
||||
|
||||
/* --- SVG Background Image --- */
|
||||
/* Generated using SVG data URI. You can create more complex SVGs. */
|
||||
/* This example creates three large arcs from corners/edges */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3C!-- Arc 1: Top Left --%3E%3Cpath d='M 0,0 L 0,50 A 50 50 0 0 1 50,0 Z' fill='rgba(174, 203, 255, 0.3)' /%3E%3C!-- Arc 2: Bottom Right --%3E%3Cpath d='M 100,100 L 50,100 A 50 50 0 0 1 100,50 Z' fill='rgba(255, 193, 174, 0.2)' /%3E%3C!-- Arc 3: Bottom Left --%3E%3Cpath d='M 0,100 L 0,70 A 80 80 0 0 0 70,100 Z' fill='rgba(167, 255, 174, 0.25)' /%3E%3C/svg%3E");
|
||||
|
||||
background-repeat: no-repeat;
|
||||
/* Adjust background size and position as needed */
|
||||
/* 'cover' might distort arcs, 'contain' might leave gaps */
|
||||
/* Using fixed size/positioning might be better */
|
||||
background-size: 100% 100%; /* Stretch SVG to container */
|
||||
/* Or position specific SVGs: */
|
||||
/* background-position: top left, bottom right, bottom left; */
|
||||
/* background-size: 50% auto, 50% auto, 80% auto; */
|
||||
}
|
||||
|
||||
|
||||
/* --- Styles applied ONLY when logged in (Grid Layout) --- */
|
||||
.appContainer.loggedInLayout {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: 2fr 2fr 6fr 2fr; /* Default large screen */
|
||||
/* Ensure grid layout itself doesn't have conflicting background */
|
||||
background-color: transparent; /* Make grid container transparent */
|
||||
}
|
||||
|
||||
/* Assign components to specific grid columns ONLY when logged in */
|
||||
/* Make sure children have backgrounds so fixed background doesn't show through */
|
||||
.appContainer.loggedInLayout > :nth-child(1) { /* LeftSidebar */
|
||||
grid-column: 1 / 2;
|
||||
min-height: 100vh;
|
||||
background-color: var(--white-color); /* Give sidebar a background */
|
||||
z-index: 2; /* Ensure sidebar is above background */
|
||||
}
|
||||
|
||||
.appContainer.loggedInLayout > :nth-child(2) { /* MainContent */
|
||||
grid-column: 3 / 4;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
background-color: transparent; /* Let appContainer::before show through blank columns */
|
||||
z-index: 2; /* Ensure content is above background */
|
||||
}
|
||||
|
||||
|
||||
/* --- Responsive Breakpoints for the LOGGED-IN layout --- */
|
||||
@media (max-width: 1200px) and (min-width: 1000px) {
|
||||
.appContainer.loggedInLayout {
|
||||
grid-template-columns: 2fr 2fr 8fr;
|
||||
}
|
||||
.appContainer.loggedInLayout > :nth-child(1) { grid-column: 1 / 2; }
|
||||
.appContainer.loggedInLayout > :nth-child(2) { grid-column: 3 / 4; }
|
||||
}
|
||||
@media (max-width: 1000px) and (min-width: 768px) {
|
||||
.appContainer.loggedInLayout {
|
||||
grid-template-columns: 2fr 10fr;
|
||||
}
|
||||
.appContainer.loggedInLayout > :nth-child(1) { grid-column: 1 / 2; }
|
||||
.appContainer.loggedInLayout > :nth-child(2) { grid-column: 2 / 3; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.appContainer.loggedInLayout {
|
||||
display: block; /* Revert to block for mobile when logged in */
|
||||
}
|
||||
.appContainer.loggedInLayout > :nth-child(1) { grid-column: auto; }
|
||||
.appContainer.loggedInLayout > :nth-child(2) { grid-column: auto; padding: var(--spacing-md); }
|
||||
}
|
||||
|
||||
/* --- End Logged-in Styles --- */
|
||||
|
||||
183
frontend_react/src/components/LeftSidebar/LeftSidebar.jsx
Normal file
183
frontend_react/src/components/LeftSidebar/LeftSidebar.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
// frontend/src/components/LeftSidebar/LeftSidebar.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styles from './LeftSidebar.module.css';
|
||||
// Import createProject API function
|
||||
import { fetchProjects, createProject } from '../../services/api';
|
||||
import { FaGithub, FaCog, FaPlus, FaUserCircle, FaSignOutAlt } from 'react-icons/fa';
|
||||
|
||||
/**
|
||||
* LeftSidebar Component
|
||||
* Fetches projects, displays them, handles creating new projects,
|
||||
* and calls onProjectSelect when one is clicked.
|
||||
* Highlights the selected project based on currentProjectId prop.
|
||||
*/
|
||||
function LeftSidebar({ onLogout, onProjectSelect, currentProjectId }) {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true); // Loading state for initial fetch
|
||||
const [isCreating, setIsCreating] = useState(false); // Loading state for creating project
|
||||
const [error, setError] = useState(null);
|
||||
const [username, setUsername] = useState('Gellar'); // Placeholder
|
||||
|
||||
useEffect(() => {
|
||||
// Placeholder: fetch or get username from context/auth state
|
||||
// setUsername(fetchedUsername);
|
||||
}, []);
|
||||
|
||||
// Function to fetch projects, reusable
|
||||
const loadProjects = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchProjects();
|
||||
console.log("LeftSidebar: Fetched projects data:", data);
|
||||
setProjects(data || []);
|
||||
return data || []; // Return fetched data
|
||||
} catch (err) {
|
||||
if (err.message === "Authentication failed. Please log in again.") {
|
||||
setError('Authentication error. Please log in.');
|
||||
} else {
|
||||
console.error("LeftSidebar: Failed to fetch projects:", err);
|
||||
setError('Failed to load projects.');
|
||||
}
|
||||
setProjects([]); // Clear projects on error
|
||||
if(onProjectSelect) {
|
||||
onProjectSelect(null); // Clear selection in App on error
|
||||
}
|
||||
return []; // Return empty array on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Initial load and setting initial selection
|
||||
useEffect(() => {
|
||||
loadProjects().then(initialProjects => {
|
||||
// Set initial project selection if none is selected yet
|
||||
if (initialProjects && initialProjects.length > 0 && currentProjectId === null && onProjectSelect) {
|
||||
console.log("LeftSidebar: Setting initial project:", initialProjects[0].id);
|
||||
onProjectSelect(initialProjects[0].id);
|
||||
} else if ((!initialProjects || initialProjects.length === 0) && onProjectSelect) {
|
||||
onProjectSelect(null);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onProjectSelect]); // Run only once on mount conceptually (onProjectSelect should be stable)
|
||||
|
||||
|
||||
const handleSelectProject = (projectId) => {
|
||||
if (onProjectSelect) {
|
||||
onProjectSelect(projectId);
|
||||
}
|
||||
console.log("LeftSidebar: Selected project:", projectId);
|
||||
};
|
||||
|
||||
// --- Updated handleNewProject ---
|
||||
const handleNewProject = async () => {
|
||||
console.log("Create new project clicked");
|
||||
const name = prompt("Enter new project name:"); // Get name from user
|
||||
|
||||
if (name && name.trim() !== '') {
|
||||
setIsCreating(true); // Set loading state for creation
|
||||
setError(null); // Clear previous errors
|
||||
try {
|
||||
// Call the createProject API function
|
||||
const newProjectData = await createProject({ name: name.trim() });
|
||||
console.log("LeftSidebar: Project created successfully:", newProjectData);
|
||||
|
||||
// Refresh the project list to include the new one
|
||||
await loadProjects(); // Reuse the fetching logic
|
||||
|
||||
// Automatically select the newly created project
|
||||
// Ensure newProjectData.id exists (mapped in api.js)
|
||||
if (newProjectData && newProjectData.id && onProjectSelect) {
|
||||
console.log("LeftSidebar: Selecting newly created project:", newProjectData.id);
|
||||
onProjectSelect(newProjectData.id);
|
||||
} else {
|
||||
console.warn("LeftSidebar: Could not get ID of newly created project to select it.");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("LeftSidebar: Failed to create project:", error);
|
||||
setError(`Error creating project: ${error.message}`); // Show error specific to creation
|
||||
alert(`Error creating project: ${error.message}`); // Also show alert
|
||||
} finally {
|
||||
setIsCreating(false); // Reset loading state
|
||||
}
|
||||
} else if (name !== null) { // Only show alert if prompt wasn't cancelled
|
||||
alert("Project name cannot be empty.");
|
||||
}
|
||||
};
|
||||
// --- End updated handleNewProject ---
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
if (onLogout && typeof onLogout === 'function') {
|
||||
onLogout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.logoSection}>
|
||||
<span className={styles.logo}>Icon</span>
|
||||
<span className={styles.appName}>SurfSmart</span>
|
||||
</div>
|
||||
|
||||
{/* Disable button while creating */}
|
||||
<button
|
||||
className={styles.newProjectButton}
|
||||
onClick={handleNewProject}
|
||||
disabled={isCreating || isLoading} // Disable if loading initial list or creating
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'NEW'}
|
||||
{!isCreating && <FaPlus className={styles.newProjectIcon} />}
|
||||
</button>
|
||||
|
||||
{/* Display creation error */}
|
||||
{error && !isLoading && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<nav className={styles.projectList}>
|
||||
{isLoading && <p>Loading projects...</p>}
|
||||
{!isLoading && !error && projects.map(project => (
|
||||
<a
|
||||
key={project.id}
|
||||
href="#"
|
||||
className={`${styles.projectItem} ${project.id === currentProjectId ? styles.selected : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Prevent selection change while creating a new project
|
||||
if (!isCreating) {
|
||||
handleSelectProject(project.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</a>
|
||||
))}
|
||||
{!isLoading && !error && projects.length === 0 && (
|
||||
<p className={styles.noProjects}>No projects yet. Click NEW!</p>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className={styles.bottomSection}>
|
||||
<div className={styles.accountInfoCapsule} title={`Logged in as ${username}`}>
|
||||
<FaUserCircle className={styles.avatarPlaceholder} />
|
||||
<span className={styles.usernameDisplay}>{username}</span>
|
||||
</div>
|
||||
<div className={styles.actionIcons}>
|
||||
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className={styles.iconLink} title="GitHub">
|
||||
<FaGithub />
|
||||
</a>
|
||||
<a href="#" className={styles.iconLink} title="Settings">
|
||||
<FaCog />
|
||||
</a>
|
||||
<button onClick={handleLogoutClick} className={`${styles.iconLink} ${styles.logoutButton}`} title="Logout">
|
||||
<FaSignOutAlt />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeftSidebar;
|
||||
213
frontend_react/src/components/LeftSidebar/LeftSidebar.module.css
Normal file
213
frontend_react/src/components/LeftSidebar/LeftSidebar.module.css
Normal file
@@ -0,0 +1,213 @@
|
||||
/* components/LeftSidebar/LeftSidebar.module.css */
|
||||
.sidebar {
|
||||
background-color: var(--background-color); /* White background */
|
||||
/* padding: 20px; */ /* Use variable */
|
||||
padding: var(--spacing-lg) var(--spacing-md); /* Adjust padding */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color); /* Use variable */
|
||||
box-shadow: var(--shadow-sm); /* Subtle shadow */
|
||||
box-sizing: border-box;
|
||||
grid-row: 1 / -1;
|
||||
transition: var(--transition-base); /* Add transition for potential future changes like collapse */
|
||||
}
|
||||
|
||||
.logoSection {
|
||||
margin-bottom: var(--spacing-xl); /* Use variable */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--spacing-xs); /* Align with project items */
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: bold;
|
||||
margin-right: var(--spacing-sm);
|
||||
/* Placeholder style */
|
||||
border: 1px solid #ccc;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600; /* Slightly bolder */
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
|
||||
.newProjectButton {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-color-light);
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: var(--border-radius-md); /* Use variable */
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
margin-bottom: var(--spacing-lg); /* Use variable */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-base); /* Use variable */
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.newProjectButton:hover {
|
||||
background-color: var(--primary-hover-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px); /* Subtle lift */
|
||||
}
|
||||
|
||||
.newProjectButton:active {
|
||||
background-color: var(--primary-active-color);
|
||||
transform: translateY(0px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.newProjectIcon {
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.projectList {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.projectItem {
|
||||
display: block;
|
||||
padding: var(--spacing-sm) var(--spacing-md); /* Adjust padding */
|
||||
margin-bottom: var(--spacing-xs); /* Use variable */
|
||||
text-decoration: none;
|
||||
color: var(--text-color-secondary); /* Use variable */
|
||||
border-radius: var(--border-radius-md); /* Use variable */
|
||||
transition: var(--transition-fast); /* Use variable */
|
||||
font-weight: 500;
|
||||
position: relative; /* For potential ::before pseudo-element */
|
||||
}
|
||||
|
||||
.projectItem:hover {
|
||||
background-color: var(--light-color); /* Use variable */
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
|
||||
.projectItem.selected {
|
||||
background-color: var(--primary-color); /* Use variable */
|
||||
font-weight: 600;
|
||||
color: var(--text-color-light); /* Use variable */
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); /* Inner shadow for selected */
|
||||
}
|
||||
|
||||
/* Optional: Add a small indicator bar for selected item */
|
||||
.projectItem.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
width: 3px;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
|
||||
.noProjects {
|
||||
color: var(--text-color-secondary);
|
||||
font-style: italic;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
margin-top: auto;
|
||||
padding-top: var(--spacing-md); /* Use variable */
|
||||
border-top: 1px solid var(--border-color); /* Use variable */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm); /* Use variable */
|
||||
}
|
||||
|
||||
/* --- Styles for Account Info --- */
|
||||
.accountInfoCapsule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: transparent; /* Make transparent */
|
||||
border: 1px solid var(--border-color); /* Add border */
|
||||
border-radius: 20px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm); /* Adjust padding */
|
||||
cursor: default;
|
||||
transition: var(--transition-fast);
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accountInfoCapsule:hover {
|
||||
background-color: var(--light-color); /* Light bg on hover */
|
||||
border-color: #bbb; /* Slightly darker border */
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
font-size: 1.4em;
|
||||
color: var(--text-color-secondary);
|
||||
margin-right: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usernameDisplay {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* --- End Account Info Styles --- */
|
||||
|
||||
.actionIcons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md); /* Increase gap slightly */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconLink {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 1.2em; /* Slightly smaller icons */
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs); /* Add padding for easier click */
|
||||
border-radius: 50%; /* Make icon background circular on hover */
|
||||
}
|
||||
|
||||
.iconLink:hover {
|
||||
color: var(--text-color-primary);
|
||||
background-color: var(--light-color); /* Add background on hover */
|
||||
}
|
||||
|
||||
/* --- Logout Button Style --- */
|
||||
.logoutButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--spacing-xs); /* Match iconLink padding */
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: var(--danger-color); /* Use variable */
|
||||
font-size: 1.2em; /* Match iconLink size */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 50%; /* Match iconLink radius */
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.logoutButton:hover {
|
||||
color: var(--text-color-light);
|
||||
background-color: var(--danger-color); /* Red background on hover */
|
||||
}
|
||||
/* --- End Logout Button Style --- */
|
||||
|
||||
.error {
|
||||
color: var(--danger-color);
|
||||
font-size: 0.9em;
|
||||
padding: 0 var(--spacing-md); /* Add padding */
|
||||
}
|
||||
156
frontend_react/src/components/LoginPage/LoginPage.jsx
Normal file
156
frontend_react/src/components/LoginPage/LoginPage.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { loginUser } from '../../services/api'; // Import the login API function
|
||||
import styles from './LoginPage.module.css';
|
||||
// Optional: Import an icon library, e.g., react-icons
|
||||
import { FaEye, FaEyeSlash } from 'react-icons/fa'; // Example using Font Awesome icons
|
||||
|
||||
/**
|
||||
* LoginPage Component
|
||||
*
|
||||
* Provides a form for users to log in using username and password.
|
||||
* Handles input, submission, API calls, token storage, and error display.
|
||||
*/
|
||||
function LoginPage({ onLoginSuccess }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await loginUser(username, password);
|
||||
|
||||
// --- DEBUGGING LOGS ---
|
||||
console.log('Login API response received:', response); // Log the entire response object
|
||||
// --- END DEBUGGING LOGS ---
|
||||
|
||||
if (response && response.token) { // Added check for response object itself
|
||||
// --- DEBUGGING LOGS ---
|
||||
console.log('Token found in response, attempting to store:', response.token);
|
||||
// --- END DEBUGGING LOGS ---
|
||||
|
||||
localStorage.setItem('authToken', response.token);
|
||||
|
||||
// --- DEBUGGING LOGS ---
|
||||
// Verify immediately after setting
|
||||
const storedToken = localStorage.getItem('authToken');
|
||||
console.log('Token potentially stored. Value in localStorage:', storedToken);
|
||||
if (storedToken !== response.token) {
|
||||
console.error("!!! Token mismatch after setting in localStorage !!!");
|
||||
}
|
||||
// --- END DEBUGGING LOGS ---
|
||||
|
||||
|
||||
console.log('Login successful, proceeding...');
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
// --- DEBUGGING LOGS ---
|
||||
console.log('No token found in API response object.');
|
||||
// --- END DEBUGGING LOGS ---
|
||||
setError('Login failed: No token received from server.'); // Updated error message
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Login failed. Please check your credentials.');
|
||||
console.error("Login error object:", err); // Log the full error object
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.loginContainer}>
|
||||
<div className={styles.loginBox}>
|
||||
<h1 className={styles.title}>Login</h1>
|
||||
<p className={styles.subtitle}>Access your SurfSmart dashboard</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* --- Username Input Group --- */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label htmlFor="username">Username</label>
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your username"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Password Input Group --- */}
|
||||
<div className={styles.inputGroup}>
|
||||
<label htmlFor="password">Password</label>
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={styles.passwordToggle}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
disabled={isLoading}
|
||||
title={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <FaEyeSlash size={18} /> : <FaEye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Error Message Display Area --- */}
|
||||
{error && (
|
||||
<div role="alert" className={styles.errorMessage}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Login Button --- */}
|
||||
<button
|
||||
type="submit"
|
||||
className={`${styles.loginButton} ${isLoading ? styles.loading : ''}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className={styles.spinner}></span>
|
||||
<span className={styles.buttonText}>Logging in...</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.buttonText}>Login</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* --- Optional Links: Register or Forgot Password --- */}
|
||||
<div className={styles.links}>
|
||||
<a href="/register">Don't have an account? Sign Up</a>
|
||||
<a href="/forgot-password">Forgot Password?</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
281
frontend_react/src/components/LoginPage/LoginPage.module.css
Normal file
281
frontend_react/src/components/LoginPage/LoginPage.module.css
Normal file
@@ -0,0 +1,281 @@
|
||||
/* components/LoginPage/LoginPage.module.css */
|
||||
|
||||
/* Define global or component-scoped CSS Variables */
|
||||
/* Preferably place these in :root or a global CSS file */
|
||||
:global(:root) { /* Use :global if this is module CSS and you want to define global variables */
|
||||
--primary-color: #007bff; /* Primary theme color */
|
||||
--primary-hover-color: #0056b3; /* Primary hover color */
|
||||
--primary-active-color: #004085; /* Primary active color */
|
||||
--error-color: #dc3545; /* Error state color */
|
||||
--error-background-color: rgba(220, 53, 69, 0.08); /* Error background */
|
||||
--error-border-color: rgba(220, 53, 69, 0.2); /* Error border */
|
||||
--success-color: #28a745; /* Success state color */
|
||||
--input-border-color: #ced4da; /* Input border color */
|
||||
--input-focus-border-color: var(--primary-color); /* Input focus border */
|
||||
--input-focus-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15); /* Input focus shadow */
|
||||
--text-color-primary: #212529; /* Primary text color */
|
||||
--text-color-secondary: #6c757d; /* Secondary text color */
|
||||
--text-color-button: #ffffff; /* Button text color */
|
||||
/* Define colors for the animated gradient */
|
||||
--gradient-color-1: #aecbff;
|
||||
--gradient-color-2: #ff7b7b;
|
||||
--gradient-color-3: #c8df66;
|
||||
--gradient-color-4: #0073ff;
|
||||
--background-card: #ffffff; /* Card background color */
|
||||
--font-family-base: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; /* Base font family */
|
||||
--border-radius-base: 6px; /* Base border radius */
|
||||
--transition-base: all 0.2s ease-in-out; /* Base transition */
|
||||
}
|
||||
|
||||
/* Keyframes for the background gradient animation */
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.loginContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
/* Updated: Apply an animated linear gradient background */
|
||||
background: linear-gradient(-45deg, var(--gradient-color-1), var(--gradient-color-2), var(--gradient-color-3), var(--gradient-color-4));
|
||||
background-size: 400% 400%; /* Make gradient larger than the container */
|
||||
animation: gradientShift 15s ease infinite; /* Apply the animation */
|
||||
padding: 20px;
|
||||
font-family: var(--font-family-base); /* Apply base font */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loginBox {
|
||||
background-color: var(--background-card); /* Use variable */
|
||||
padding: 40px 35px; /* Slightly adjust padding */
|
||||
border-radius: var(--border-radius-base); /* Use variable */
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); /* Softer shadow */
|
||||
width: 100%;
|
||||
max-width: 420px; /* Slightly increase width */
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
z-index: 1; /* Ensure login box is above the background */
|
||||
}
|
||||
|
||||
/* Optional: Logo styles */
|
||||
.logo {
|
||||
max-width: 150px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 2.2em; /* Increase title font size */
|
||||
font-weight: 600; /* Adjust font weight */
|
||||
color: var(--text-color-primary); /* Use variable */
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-bottom: 35px; /* Increase bottom margin */
|
||||
color: var(--text-color-secondary); /* Use variable */
|
||||
font-size: 1.05em; /* Adjust font size */
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
margin-bottom: 22px; /* Adjust margin */
|
||||
text-align: left;
|
||||
position: relative; /* Provide base for absolute positioning inside */
|
||||
}
|
||||
|
||||
.inputGroup label {
|
||||
display: block;
|
||||
margin-bottom: 8px; /* Adjust margin below label */
|
||||
font-weight: 500;
|
||||
color: var(--text-color-primary); /* Use variable */
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Wrapper for input and icon/button */
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
display: flex; /* For aligning icon and input */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Optional: Styles for icon next to input */
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-color-secondary);
|
||||
pointer-events: none; /* Prevent icon from interfering with clicks */
|
||||
z-index: 1; /* Ensure it's above input background */
|
||||
}
|
||||
|
||||
.inputGroup input {
|
||||
width: 100%;
|
||||
padding: 12px 15px; /* Base padding */
|
||||
/* Increase left padding if using left icon */
|
||||
/* padding-left: 40px; */
|
||||
/* Increase right padding if using right button (password toggle) */
|
||||
padding-right: 45px;
|
||||
border: 1px solid var(--input-border-color); /* Use variable */
|
||||
border-radius: var(--border-radius-base); /* Use variable */
|
||||
font-size: 1em;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-color-primary);
|
||||
background-color: var(--background-card);
|
||||
transition: var(--transition-base); /* Apply base transition */
|
||||
}
|
||||
|
||||
.inputGroup input::placeholder {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.inputGroup input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border-color); /* Use variable */
|
||||
box-shadow: var(--input-focus-shadow); /* Use variable */
|
||||
}
|
||||
|
||||
/* Style when input is invalid */
|
||||
.inputGroup input[aria-invalid="true"] {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
.inputGroup input[aria-invalid="true"]:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.15); /* Focus shadow for error state */
|
||||
}
|
||||
|
||||
|
||||
/* Password visibility toggle button styles */
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
right: 0px; /* Position to the right */
|
||||
top: 0;
|
||||
height: 100%; /* Same height as input */
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 12px; /* Left/right padding */
|
||||
cursor: pointer;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.passwordToggle:hover,
|
||||
.passwordToggle:focus {
|
||||
color: var(--text-color-primary);
|
||||
outline: none; /* Remove default outline, rely on parent focus style */
|
||||
}
|
||||
|
||||
.passwordToggle:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Error message styles */
|
||||
.errorMessage {
|
||||
color: var(--error-color); /* Use variable */
|
||||
background-color: var(--error-background-color); /* Use variable */
|
||||
border: 1px solid var(--error-border-color); /* Use variable */
|
||||
padding: 10px 15px;
|
||||
border-radius: var(--border-radius-base); /* Use variable */
|
||||
margin-top: 5px; /* Space between error and input */
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.9em;
|
||||
text-align: left; /* Align error message left */
|
||||
display: flex; /* For aligning optional icon */
|
||||
align-items: center;
|
||||
transition: var(--transition-base); /* Apply base transition */
|
||||
}
|
||||
|
||||
/* Optional: Error icon styles */
|
||||
.errorIcon {
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0; /* Prevent icon from shrinking */
|
||||
}
|
||||
|
||||
/* Login button styles */
|
||||
.loginButton {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
background-color: var(--primary-color); /* Use variable */
|
||||
color: var(--text-color-button); /* Use variable */
|
||||
border: none;
|
||||
border-radius: var(--border-radius-base); /* Use variable */
|
||||
font-size: 1.1em;
|
||||
font-weight: 600; /* Slightly bolder text */
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base); /* Apply base transition */
|
||||
margin-top: 15px; /* Space above button */
|
||||
position: relative; /* For spinner positioning */
|
||||
overflow: hidden; /* Hide overflow if spinner is absolutely positioned */
|
||||
display: flex; /* Use flex to center content */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px; /* Space between text and spinner */
|
||||
}
|
||||
|
||||
.loginButton:hover {
|
||||
background-color: var(--primary-hover-color); /* Use variable */
|
||||
}
|
||||
|
||||
.loginButton:active {
|
||||
background-color: var(--primary-active-color); /* Use variable */
|
||||
transform: translateY(1px); /* Subtle press effect */
|
||||
}
|
||||
|
||||
.loginButton:disabled {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65; /* Adjust opacity for disabled state */
|
||||
}
|
||||
|
||||
/* Hide text when loading, spinner will be shown */
|
||||
.loginButton.loading .buttonText {
|
||||
/* Optional: uncomment to hide text when loading */
|
||||
/* display: none; */
|
||||
}
|
||||
|
||||
/* Loading spinner styles */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1em; /* Relative to font size */
|
||||
height: 1em; /* Relative to font size */
|
||||
border: 2px solid rgba(255, 255, 255, 0.3); /* Lighter border */
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--text-color-button); /* Spinner color */
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
vertical-align: middle; /* Align with text */
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Optional links styles */
|
||||
.links {
|
||||
margin-top: 25px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6; /* Add line-height for better spacing when stacked */
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--primary-color); /* Use variable */
|
||||
text-decoration: none;
|
||||
/* Updated: Make links block elements to stack vertically */
|
||||
display: block;
|
||||
/* Updated: Remove horizontal margin, add vertical margin */
|
||||
margin: 8px 0; /* Add some space between stacked links */
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-hover-color); /* Use variable */
|
||||
}
|
||||
291
frontend_react/src/components/MainContent/MainContent.jsx
Normal file
291
frontend_react/src/components/MainContent/MainContent.jsx
Normal file
@@ -0,0 +1,291 @@
|
||||
// frontend/src/components/MainContent/MainContent.jsx
|
||||
import React, { useState, useEffect, useRef } from 'react'; // Import useRef
|
||||
import ProjectHeader from '../ProjectHeader/ProjectHeader.jsx';
|
||||
import UrlCardList from '../UrlCardList/UrlCardList.jsx';
|
||||
import UrlDetailPage from '../UrlDetailPage/UrlDetailPage.jsx';
|
||||
import styles from './MainContent.module.css';
|
||||
import {
|
||||
fetchProjectDetails,
|
||||
fetchProjectUrls,
|
||||
addUrlToProject,
|
||||
askAiAboutProject,
|
||||
deleteUrlFromProject,
|
||||
regenerateSummary,
|
||||
fetchUrlDetails // Import fetchUrlDetails for polling
|
||||
} from '../../services/api';
|
||||
import { FaPlus, FaMagic } from 'react-icons/fa';
|
||||
|
||||
// --- Constants ---
|
||||
const POLLING_INTERVAL_MS = 5000; // Check every 5 seconds
|
||||
|
||||
function MainContent({ currentProjectId }) {
|
||||
const [projectDetails, setProjectDetails] = useState(null);
|
||||
const [urls, setUrls] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [aiResponse, setAiResponse] = useState('');
|
||||
const [detailUrlId, setDetailUrlId] = useState(null);
|
||||
|
||||
// --- Polling State ---
|
||||
// Use useRef to store the interval ID so it doesn't trigger re-renders
|
||||
const pollIntervalRef = useRef(null);
|
||||
// --- End Polling State ---
|
||||
|
||||
// Function to update a single URL in the state
|
||||
const updateSingleUrlState = (updatedUrlData) => {
|
||||
if (!updatedUrlData || !updatedUrlData.id) return;
|
||||
setUrls(currentUrls => {
|
||||
// Create a flag to see if an update actually happened
|
||||
let updated = false;
|
||||
const newUrls = currentUrls.map(url => {
|
||||
if (url.id === updatedUrlData.id) {
|
||||
// Only update if data has actually changed to avoid infinite loops
|
||||
// Compare relevant fields like status, title, summary
|
||||
if (url.processingStatus !== updatedUrlData.processingStatus ||
|
||||
url.title !== updatedUrlData.title ||
|
||||
url.summary !== updatedUrlData.summary) {
|
||||
updated = true;
|
||||
return { ...url, ...updatedUrlData, isLoading: false }; // Merge new data
|
||||
}
|
||||
}
|
||||
return url;
|
||||
});
|
||||
// Only set state if an update occurred
|
||||
return updated ? newUrls : currentUrls;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Effect for initial data load when project changes
|
||||
useEffect(() => {
|
||||
setDetailUrlId(null);
|
||||
// Clear any existing polling interval when project changes
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!currentProjectId) {
|
||||
setProjectDetails(null); setUrls([]); setIsLoading(false); setError(null);
|
||||
return;
|
||||
};
|
||||
setIsLoading(true); setError(null); setAiResponse('');
|
||||
Promise.all([ fetchProjectDetails(currentProjectId), fetchProjectUrls(currentProjectId) ])
|
||||
.then(([details, urlsData]) => {
|
||||
setProjectDetails(details); setUrls(urlsData || []); setIsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("MainContent: Failed to load project data:", err);
|
||||
// ... (error handling as before) ...
|
||||
setError(`Failed to load data for project ${currentProjectId}.`);
|
||||
setProjectDetails(null); setUrls([]); setIsLoading(false);
|
||||
});
|
||||
|
||||
// Cleanup function for when component unmounts or currentProjectId changes
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
console.log("Polling interval cleared on project change/unmount.");
|
||||
}
|
||||
};
|
||||
}, [currentProjectId]);
|
||||
|
||||
|
||||
// --- Effect for Polling Pending URLs ---
|
||||
useEffect(() => {
|
||||
const pendingUrls = urls.filter(url => url.processingStatus === 'pending');
|
||||
|
||||
if (pendingUrls.length > 0 && !pollIntervalRef.current) {
|
||||
// Start polling only if there are pending URLs and polling isn't already running
|
||||
console.log(`Polling started for ${pendingUrls.length} pending URL(s).`);
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
console.log("Polling: Checking status of pending URLs...");
|
||||
const currentPendingIds = urls
|
||||
.filter(u => u.processingStatus === 'pending')
|
||||
.map(u => u.id);
|
||||
|
||||
if (currentPendingIds.length === 0) {
|
||||
console.log("Polling: No more pending URLs, stopping interval.");
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch details for each pending URL
|
||||
// Using Promise.allSettled to handle individual fetch failures
|
||||
const results = await Promise.allSettled(
|
||||
currentPendingIds.map(id => fetchUrlDetails(id))
|
||||
);
|
||||
|
||||
let anyUpdates = false;
|
||||
results.forEach((result, index) => {
|
||||
const urlId = currentPendingIds[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
const updatedData = result.value;
|
||||
// Check if the status is no longer pending
|
||||
if (updatedData && updatedData.processingStatus !== 'pending') {
|
||||
console.log(`Polling: URL ${urlId} status updated to ${updatedData.processingStatus}. Updating state.`);
|
||||
updateSingleUrlState(updatedData); // Update the specific URL in state
|
||||
anyUpdates = true;
|
||||
}
|
||||
} else {
|
||||
// Handle fetch error for a specific URL during polling
|
||||
console.error(`Polling: Failed to fetch details for URL ${urlId}:`, result.reason);
|
||||
// Optionally mark this URL as failed in the state if fetch fails consistently?
|
||||
// updateSingleUrlState({ id: urlId, processingStatus: 'failed', summary: 'Failed to fetch status.' });
|
||||
// anyUpdates = true;
|
||||
}
|
||||
});
|
||||
|
||||
// If all polled URLs are now completed/failed, stop the interval early
|
||||
// Check the main 'urls' state again after potential updates
|
||||
const stillPending = urls.some(u => u.processingStatus === 'pending');
|
||||
if (!stillPending && pollIntervalRef.current) {
|
||||
console.log("Polling: All polled URLs completed/failed, stopping interval.");
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
|
||||
}, POLLING_INTERVAL_MS);
|
||||
|
||||
} else if (pendingUrls.length === 0 && pollIntervalRef.current) {
|
||||
// Stop polling if no pending URLs remain
|
||||
console.log("Polling stopped: No pending URLs.");
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Cleanup function for this specific effect (when urls state changes)
|
||||
// This ensures the interval is cleared if the component unmounts while polling
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
// console.log("Polling interval cleared on effect cleanup.");
|
||||
// Setting ref to null here might cause issues if another effect relies on it immediately
|
||||
// It's better handled by the main cleanup in the projectId effect
|
||||
}
|
||||
};
|
||||
}, [urls]); // Re-run this effect whenever the urls state changes
|
||||
// --- End Polling Effect ---
|
||||
|
||||
|
||||
const handleViewUrlDetails = (urlId) => { setDetailUrlId(urlId); };
|
||||
const handleBackToList = () => { setDetailUrlId(null); };
|
||||
|
||||
const handleAddUrl = () => {
|
||||
// ... (implementation as before, setting initial status to 'pending') ...
|
||||
if (!currentProjectId) { alert("Please select a project first."); return; }
|
||||
let newUrl = prompt("Enter the new URL (e.g., https://example.com or example.com):");
|
||||
|
||||
if (newUrl && newUrl.trim() !== '') {
|
||||
let processedUrl = newUrl.trim();
|
||||
if (!/^(https?:\/\/|\/\/)/i.test(processedUrl)) {
|
||||
processedUrl = 'https://' + processedUrl;
|
||||
}
|
||||
|
||||
const placeholderId = `temp-${Date.now()}`;
|
||||
const placeholderCard = {
|
||||
id: placeholderId, url: processedUrl, title: '(Processing...)',
|
||||
summary: '', note: '', keywords: [], starred: false, favicon: null,
|
||||
processingStatus: 'pending',
|
||||
};
|
||||
setUrls(prevUrls => [placeholderCard, ...prevUrls]);
|
||||
|
||||
addUrlToProject(currentProjectId, processedUrl)
|
||||
.then(addedUrlData => {
|
||||
setUrls(prevUrls => prevUrls.map(url => {
|
||||
if (url.id === placeholderId) {
|
||||
return {
|
||||
id: addedUrlData.id, url: processedUrl, title: '', summary: '',
|
||||
note: '', keywords: [], starred: false, favicon: null,
|
||||
processingStatus: 'pending', // Set correct initial status
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()
|
||||
};
|
||||
} else { return url; }
|
||||
}));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to add URL:", err);
|
||||
setUrls(prevUrls => prevUrls.filter(url => url.id !== placeholderId));
|
||||
alert(`Failed to add URL: ${err.message || 'An unknown error occurred.'}`);
|
||||
});
|
||||
} else if (newUrl !== null) { alert("URL cannot be empty."); }
|
||||
};
|
||||
|
||||
const handleAskAi = () => { /* ... */ };
|
||||
const handleUrlOrderChange = (newOrder) => { /* ... */ };
|
||||
const handleDeleteUrl = (urlIdToDelete) => { /* ... */ };
|
||||
const handleRegenerateSummary = (urlIdToRegen) => {
|
||||
// ... (implementation as before, sets isLoading on the specific card) ...
|
||||
// This function should now also ensure the status becomes 'pending'
|
||||
// so the poller can pick it up if needed, or update directly on success.
|
||||
if (!currentProjectId) return;
|
||||
setUrls(prevUrls => prevUrls.map(url =>
|
||||
url.id === urlIdToRegen ? { ...url, isLoading: true, processingStatus: 'pending', summary: 'Regenerating...' } : url // Set status to pending
|
||||
));
|
||||
regenerateSummary(urlIdToRegen)
|
||||
.then(updatedUrlData => {
|
||||
setUrls(prevUrls => prevUrls.map(url => {
|
||||
if (url.id === urlIdToRegen) {
|
||||
// Merge result, ensure isLoading is false
|
||||
// API returns status 'pending' if queued, or full data on sync completion
|
||||
if (updatedUrlData.status === 'pending') {
|
||||
return { ...url, isLoading: false, processingStatus: 'pending', summary: 'Regeneration queued...' };
|
||||
} else {
|
||||
// Assume completion if status isn't pending
|
||||
return { ...updatedUrlData, id: urlIdToRegen, isLoading: false }; // Ensure ID is correct
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to regenerate summary:", err);
|
||||
setUrls(prevUrls => prevUrls.map(url =>
|
||||
// Set status back? Or maybe to failed? Let's mark failed.
|
||||
url.id === urlIdToRegen ? { ...url, isLoading: false, processingStatus: 'failed', summary: 'Regeneration failed.' } : url
|
||||
));
|
||||
alert(`Regeneration failed: ${err.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// --- Render Logic ---
|
||||
if (isLoading) return <div className={styles.loading}>Loading project data...</div>;
|
||||
if (!currentProjectId && !isLoading) return <div className={styles.noProjectSelected}>Select a project from the sidebar to view details.</div>;
|
||||
if (error && !detailUrlId) return <div className={styles.error}>{error}</div>;
|
||||
if (!projectDetails && !isLoading && !error && currentProjectId && !detailUrlId) return <div className={styles.error}>Could not load details for the selected project.</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.mainContent} key={currentProjectId}>
|
||||
{detailUrlId ? (
|
||||
<UrlDetailPage urlId={detailUrlId} onBack={handleBackToList} />
|
||||
) : (
|
||||
<>
|
||||
{projectDetails && ( <ProjectHeader /* ...props... */ /> )}
|
||||
<UrlCardList
|
||||
urls={urls}
|
||||
onOrderChange={handleUrlOrderChange}
|
||||
onDelete={handleDeleteUrl}
|
||||
onRegenerate={handleRegenerateSummary}
|
||||
onViewDetails={handleViewUrlDetails}
|
||||
/>
|
||||
<div className={styles.actionBar}>
|
||||
{/* ... action buttons ... */}
|
||||
<button className={styles.actionButton} onClick={handleAskAi} disabled={!currentProjectId || isLoading}>
|
||||
<FaMagic className={styles.actionIcon} /> Ask AI
|
||||
</button>
|
||||
<button className={styles.actionButton} onClick={handleAddUrl} disabled={!currentProjectId || isLoading}>
|
||||
Add url <FaPlus className={styles.actionIcon} />
|
||||
</button>
|
||||
</div>
|
||||
{aiResponse && <div className={styles.aiResponseArea}>{aiResponse}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainContent;
|
||||
113
frontend_react/src/components/MainContent/MainContent.module.css
Normal file
113
frontend_react/src/components/MainContent/MainContent.module.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* components/MainContent/MainContent.module.css */
|
||||
.mainContent {
|
||||
flex: 1 1 auto;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading, .error, .noProjectSelected {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color-secondary); /* Use variable */
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger-color); /* Use variable */
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color); /* Use variable */
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
/* Base styles */
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
transition: var(--transition-base);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.actionButton:active {
|
||||
transform: translateY(0px);
|
||||
box-shadow: none;
|
||||
}
|
||||
.actionButton:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
/* --- Updated AI Button Styles --- */
|
||||
/* Ask AI Button (Assuming it's the first button) */
|
||||
.actionButton:first-child {
|
||||
background-color: var(--ai-background); /* Use light background */
|
||||
color: var(--ai-text); /* Use AI text color (dark red) */
|
||||
border-color: var(--ai-text); /* Use AI text color for border */
|
||||
}
|
||||
.actionButton:first-child:hover {
|
||||
background-color: var(--ai-background-hover); /* Very subtle red background */
|
||||
color: var(--ai-text-hover);
|
||||
border-color: var(--ai-text-hover);
|
||||
}
|
||||
.actionButton:first-child:active {
|
||||
background-color: var(--ai-background-activate); /* Slightly darker subtle red background */
|
||||
color: var(--ai-text-activate);
|
||||
border-color: var(--ai-text-activate);
|
||||
}
|
||||
/* --- End AI Button Styles --- */
|
||||
|
||||
|
||||
/* Add URL Button (Assuming it's the last button) */
|
||||
.actionButton:last-child {
|
||||
background-color: var(--success-color); /* Use variable */
|
||||
color: var(--text-color-light); /* Use variable */
|
||||
}
|
||||
.actionButton:last-child:hover {
|
||||
background-color: #58a85c; /* Slightly darker success */
|
||||
}
|
||||
.actionButton:last-child:active {
|
||||
background-color: #4a9b4f;
|
||||
}
|
||||
|
||||
|
||||
.actionIcon {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aiResponseArea {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: #e9f5ff; /* Keep light blue or use a new variable */
|
||||
border: 1px solid #bce8f1;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: #31708f;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// --- 需要根据后端 projects.py 提供的 API 进行修改 ---
|
||||
// 1. 确认接收的 props (name, description, topic, summary, keywords) 与 MainContent 传递的一致
|
||||
// 2. 确保 WordCloud 组件能正确处理 keywords: [{word, percentage}]
|
||||
// 3. (可选) 添加编辑项目、重新计算关键词等操作的触发器 (按钮)
|
||||
// ----------------------------------------------------
|
||||
|
||||
import React from 'react';
|
||||
import styles from './ProjectHeader.module.css';
|
||||
|
||||
// Updated WordCloud component to accept keywords prop and map it
|
||||
const WordCloud = ({ keywords }) => {
|
||||
// Map backend keywords { word, percentage } to { text, value } if needed by a library
|
||||
// Or render directly
|
||||
const wordCloudData = keywords?.map(kw => ({ text: kw.word, value: kw.percentage })) || [];
|
||||
|
||||
if (!wordCloudData || wordCloudData.length === 0) {
|
||||
return <div className={styles.wordCloudPlaceholder}>No keyword data available. (Recalculate?)</div>;
|
||||
}
|
||||
|
||||
// Simple display for placeholder - Replace with actual word cloud rendering
|
||||
const maxPercentage = Math.max(...wordCloudData.map(d => d.value), 0) || 100;
|
||||
|
||||
return (
|
||||
<div className={styles.wordCloud}>
|
||||
{wordCloudData.slice(0, 20).map((item, index) => ( // Show top 20 words
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: `${10 + (item.value / maxPercentage) * 15}px`, // Example scaling
|
||||
margin: '2px 5px',
|
||||
display: 'inline-block',
|
||||
opacity: 0.6 + (item.value / maxPercentage) * 0.4, // Example opacity scaling
|
||||
color: 'var(--text-color-secondary)' // Use CSS var
|
||||
}}
|
||||
title={`${item.text} (${item.value.toFixed(1)}%)`} // Add tooltip with percentage
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* ProjectHeader Component
|
||||
* Displays the project's name, description, topic, summary and keywords.
|
||||
*/
|
||||
// Accept more props based on backend response
|
||||
function ProjectHeader({ name, description, topic, summary, keywords }) {
|
||||
// TODO: Add handlers for Edit, Recalculate Keywords, Delete Project if buttons are added
|
||||
// const handleEditClick = () => { ... };
|
||||
// const handleRecalcClick = () => { ... call recalculateProjectKeywords API ... };
|
||||
|
||||
return (
|
||||
<div className={styles.projectHeader}>
|
||||
{/* Left side: Name and Description */}
|
||||
<div className={styles.projectInfo}>
|
||||
<h1 className={styles.projectName}>{name || 'Project Name'}</h1>
|
||||
{/* Display Topic if available */}
|
||||
{topic && <p className={styles.projectTopic}>Topic: {topic}</p>}
|
||||
<p className={styles.projectDescription}>{description || 'No description provided.'}</p>
|
||||
{/* Display AI Summary if available */}
|
||||
{summary && <p className={styles.projectSummary}>AI Summary: {summary}</p>}
|
||||
{/* TODO: Add Edit button here? */}
|
||||
</div>
|
||||
|
||||
{/* Right side: Global Word Cloud */}
|
||||
<div className={styles.wordCloudContainer}>
|
||||
<h2 className={styles.wordCloudTitle}>Project Keywords</h2>
|
||||
<WordCloud keywords={keywords} />
|
||||
{/* Optional: Button to trigger recalculation */}
|
||||
{/* <button onClick={handleRecalcClick} className={styles.recalcButton}>Recalculate Keywords</button> */}
|
||||
</div>
|
||||
{/* TODO: Add Delete Project button somewhere? Maybe outside this component */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add styles for topic, summary, recalcButton in ProjectHeader.module.css if needed
|
||||
// styles.css:
|
||||
// .projectTopic { font-style: italic; color: var(--secondary-color); margin-bottom: 5px; }
|
||||
// .projectSummary { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border-color); font-size: 0.9em; color: var(--text-color-secondary); }
|
||||
// .recalcButton { margin-top: 10px; font-size: 0.8em; padding: 4px 8px; }
|
||||
|
||||
|
||||
export default ProjectHeader;
|
||||
@@ -0,0 +1,96 @@
|
||||
/* components/ProjectHeader/ProjectHeader.module.css */
|
||||
.projectHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background-color: var(--white-color); /* Use variable */
|
||||
padding: var(--spacing-lg); /* Use variable */
|
||||
border-radius: var(--border-radius-lg); /* Use variable */
|
||||
margin-bottom: var(--spacing-xl); /* Use variable */
|
||||
box-shadow: var(--shadow-md); /* Use variable */
|
||||
gap: var(--spacing-lg); /* Use variable */
|
||||
border: 1px solid var(--border-color); /* Add subtle border */
|
||||
}
|
||||
|
||||
.projectInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.projectName {
|
||||
margin: 0 0 var(--spacing-sm) 0; /* Use variable */
|
||||
font-size: 1.8em;
|
||||
font-weight: 600; /* Bolder */
|
||||
color: var(--text-color-primary); /* Use variable */
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.projectDescription {
|
||||
margin: 0;
|
||||
color: var(--text-color-secondary); /* Use variable */
|
||||
line-height: 1.6; /* Increase line height */
|
||||
}
|
||||
|
||||
.wordCloudContainer {
|
||||
flex: 0 0 35%;
|
||||
min-width: 250px; /* Increase min-width slightly */
|
||||
/* border-left: 1px solid var(--border-color); */ /* Remove border, use spacing */
|
||||
/* padding-left: var(--spacing-lg); */ /* Remove padding, rely on gap */
|
||||
background-color: var(--light-color); /* Subtle background for contrast */
|
||||
padding: var(--spacing-md); /* Add padding inside the container */
|
||||
border-radius: var(--border-radius-md); /* Round corners */
|
||||
}
|
||||
|
||||
.wordCloudTitle {
|
||||
font-size: 1.0em; /* Smaller title */
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary); /* Use variable */
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-md); /* Use variable */
|
||||
text-transform: uppercase; /* Uppercase for style */
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wordCloud {
|
||||
min-height: 100px;
|
||||
line-height: 1.9; /* Adjust for better spacing */
|
||||
text-align: center;
|
||||
/* Add some visual style */
|
||||
filter: saturate(1.1); /* Slightly more vibrant colors */
|
||||
}
|
||||
|
||||
.wordCloud span { /* Style individual words */
|
||||
cursor: default; /* Indicate non-interactive */
|
||||
transition: var(--transition-fast);
|
||||
color: var(--secondary-color); /* Base color */
|
||||
}
|
||||
/* Optional: Hover effect for words */
|
||||
/* .wordCloud span:hover {
|
||||
color: var(--primary-color);
|
||||
transform: scale(1.1);
|
||||
} */
|
||||
|
||||
|
||||
.wordCloudPlaceholder {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7; /* Make placeholder less prominent */
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.projectHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.wordCloudContainer {
|
||||
flex-basis: auto;
|
||||
margin-top: var(--spacing-lg);
|
||||
/* border-top: 1px solid var(--border-color); */ /* Remove top border */
|
||||
/* padding-top: var(--spacing-lg); */
|
||||
}
|
||||
}
|
||||
164
frontend_react/src/components/UrlCard/UrlCard.jsx
Normal file
164
frontend_react/src/components/UrlCard/UrlCard.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// frontend/src/components/UrlCard/UrlCard.jsx
|
||||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import styles from './UrlCard.module.css';
|
||||
import { FaEdit, FaTrashAlt, FaBars, FaSyncAlt, FaSpinner, FaExclamationTriangle, FaTag, FaStar, FaRegStar, FaStickyNote } from 'react-icons/fa';
|
||||
|
||||
// Simple Keyword Tag component
|
||||
const KeywordTag = ({ keyword }) => (
|
||||
<span className={styles.keywordTag}>{keyword.word}</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* UrlCard Component
|
||||
* Displays URL info, handles drag-and-drop, and now triggers onViewDetails on click.
|
||||
*/
|
||||
function UrlCard({
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
summary,
|
||||
keywords,
|
||||
processingStatus,
|
||||
favicon,
|
||||
starred,
|
||||
note,
|
||||
isLoading,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onViewDetails, // Accept the new prop
|
||||
// Add handlers for starring/editing notes if implemented
|
||||
}) {
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners, // listeners for drag handle
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
marginBottom: '15px',
|
||||
position: 'relative',
|
||||
zIndex: isDragging ? 100 : 'auto',
|
||||
borderLeft: processingStatus === 'pending' ? '3px solid orange' : (processingStatus === 'failed' ? '3px solid red' : '3px solid transparent'),
|
||||
};
|
||||
|
||||
const handleEdit = (e) => {
|
||||
e.stopPropagation(); // Prevent card click when clicking button
|
||||
console.log("Edit clicked for:", id);
|
||||
alert(`Edit Note/Details for URL ID: ${id} (Placeholder)`);
|
||||
};
|
||||
|
||||
const handleDelete = (e) => {
|
||||
e.stopPropagation(); // Prevent card click when clicking button
|
||||
onDelete(); // Call original delete handler
|
||||
}
|
||||
|
||||
const handleRegenerate = (e) => {
|
||||
e.stopPropagation(); // Prevent card click when clicking button
|
||||
onRegenerate(); // Call original regenerate handler
|
||||
}
|
||||
|
||||
const handleStarClick = (e) => {
|
||||
e.stopPropagation(); // Prevent card click when clicking button
|
||||
// TODO: Implement star toggling logic + API call
|
||||
console.log("Star clicked for:", id);
|
||||
alert(`Toggle star for ${id} (Placeholder)`);
|
||||
}
|
||||
|
||||
// Determine content based on processing status
|
||||
let cardBody;
|
||||
if (processingStatus === 'pending') {
|
||||
cardBody = <div className={styles.statusInfo}><FaSpinner className={styles.spinnerIconSmall} /> Processing...</div>;
|
||||
} else if (processingStatus === 'failed') {
|
||||
cardBody = <div className={styles.statusInfo}><FaExclamationTriangle className={styles.errorIcon} /> Processing Failed</div>;
|
||||
} else { // completed or undefined
|
||||
cardBody = (
|
||||
<>
|
||||
<p className={styles.summary}>{summary || 'No summary available.'}</p>
|
||||
{keywords && keywords.length > 0 && (
|
||||
<div className={styles.keywordsContainer}>
|
||||
<FaTag className={styles.keywordIcon} />
|
||||
{keywords.slice(0, 5).map((kw, index) => <KeywordTag key={index} keyword={kw} />)}
|
||||
{keywords.length > 5 && <span className={styles.moreKeywords}>...</span>}
|
||||
</div>
|
||||
)}
|
||||
{note && (
|
||||
<div className={styles.noteContainer}>
|
||||
<FaStickyNote className={styles.noteIcon} />
|
||||
<span className={styles.noteText}>{note}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Click handler for the main content area ---
|
||||
const handleCardClick = () => {
|
||||
// Only navigate if not dragging
|
||||
if (!isDragging && onViewDetails) {
|
||||
onViewDetails(); // Call the handler passed from MainContent
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// setNodeRef and attributes for dnd-kit Sortable
|
||||
<div ref={setNodeRef} style={style} {...attributes} className={`${styles.card} ${isLoading ? styles.loading : ''}`}>
|
||||
{isLoading && (
|
||||
<div className={styles.spinnerOverlay}>
|
||||
<FaSpinner className={styles.spinnerIcon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left side: Buttons */}
|
||||
<div className={styles.leftColumn}>
|
||||
{favicon && <img src={favicon} alt="favicon" className={styles.favicon} onError={(e) => e.target.style.display='none'}/>}
|
||||
<button onClick={handleStarClick} className={`${styles.iconButton} ${styles.starButton}`} title={starred ? "Unstar" : "Star"}>
|
||||
{starred ? <FaStar /> : <FaRegStar />}
|
||||
</button>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
onClick={handleRegenerate} // Use specific handler
|
||||
title="Regenerate Summary/Keywords"
|
||||
disabled={isLoading || processingStatus === 'pending'}
|
||||
>
|
||||
<FaSyncAlt />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Main Content - Make this part clickable */}
|
||||
<div className={styles.cardContent} onClick={handleCardClick} style={{ cursor: 'pointer' }}> {/* Add onClick and pointer */}
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardTitle}>{title || 'No Title'}</span>
|
||||
{/* Make URL link not trigger card click? Optional, but often good UX */}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={styles.urlLink} onClick={(e) => e.stopPropagation()}>
|
||||
{url}
|
||||
</a>
|
||||
<div className={styles.cardActions}>
|
||||
<button className={styles.iconButton} onClick={handleEdit} title="Edit/View Note">
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button className={`${styles.iconButton} ${styles.deleteButton}`} onClick={handleDelete} title="Delete">
|
||||
<FaTrashAlt />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{cardBody}
|
||||
</div>
|
||||
|
||||
{/* Right side: Drag Handle - Use listeners from useSortable */}
|
||||
<div className={styles.dragHandle} {...listeners} title="Drag to reorder">
|
||||
<FaBars />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UrlCard;
|
||||
262
frontend_react/src/components/UrlCard/UrlCard.module.css
Normal file
262
frontend_react/src/components/UrlCard/UrlCard.module.css
Normal file
@@ -0,0 +1,262 @@
|
||||
/* components/UrlCard/UrlCard.module.css */
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
/* padding: 15px 20px; */ /* Padding moved to inner columns */
|
||||
display: flex;
|
||||
align-items: stretch; /* Make columns same height */
|
||||
/* gap: 15px; */ /* Replaced by padding on columns */
|
||||
position: relative; /* For spinner overlay */
|
||||
transition: box-shadow 0.2s ease, border-left 0.3s ease; /* Added border transition */
|
||||
overflow: hidden; /* Prevent content spillover */
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card.loading {
|
||||
/* Opacity handled by spinner overlay now */
|
||||
pointer-events: none; /* Prevent interaction while API call is loading */
|
||||
}
|
||||
|
||||
.spinnerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
border-radius: 8px; /* Match card radius */
|
||||
}
|
||||
|
||||
.spinnerIcon {
|
||||
font-size: 1.5em;
|
||||
color: #007bff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.spinnerIconSmall {
|
||||
font-size: 1em; /* Smaller spinner for inline status */
|
||||
color: #007bff;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* --- Column Structure --- */
|
||||
.leftColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: var(--spacing-md) var(--spacing-sm);
|
||||
gap: var(--spacing-md);
|
||||
border-right: 1px solid var(--border-color); /* Use variable */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
flex-grow: 1; /* Takes up most space */
|
||||
min-width: 0; /* Prevent overflow */
|
||||
padding: 15px; /* Padding */
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
align-items: center; /* Center icon vertically */
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
color: #adb5bd; /* Light color for handle */
|
||||
padding: 15px 10px; /* Padding */
|
||||
border-left: 1px solid #eee; /* Separator line */
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
/* --- End Column Structure --- */
|
||||
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px; /* Space between header elements */
|
||||
flex-wrap: wrap; /* Allow wrapping on smaller widths within card */
|
||||
}
|
||||
|
||||
.cardTitle { /* New style for title */
|
||||
font-weight: 600; /* Make title slightly bolder */
|
||||
color: #333;
|
||||
margin-right: auto; /* Push URL and actions to the right */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.urlLink {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis; /* Truncate long URLs */
|
||||
flex-shrink: 1; /* Allow URL to shrink */
|
||||
min-width: 50px; /* Prevent URL from becoming too small */
|
||||
margin-left: 10px; /* Space from title */
|
||||
}
|
||||
|
||||
.urlLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* Space between action icons */
|
||||
flex-shrink: 0; /* Prevent actions from shrinking */
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color-secondary); /* Use variable */
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
font-size: 0.95em;
|
||||
transition: var(--transition-fast); /* Use variable */
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.iconButton:hover {
|
||||
color: var(--text-color-primary); /* Use variable */
|
||||
background-color: var(--light-color); /* Use variable */
|
||||
}
|
||||
.iconButton:disabled {
|
||||
color: #adb5bd;
|
||||
cursor: not-allowed;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* --- Specific AI Regen Button Style --- */
|
||||
/* Target the regen button specifically if possible, otherwise rely on its position/icon */
|
||||
/* Assuming it's the last button in leftColumn for now */
|
||||
.leftColumn > .iconButton:last-child { /* Example selector */
|
||||
color: var(--ai-text); /* Use AI text color */
|
||||
}
|
||||
.leftColumn > .iconButton:last-child:hover {
|
||||
color: var(--ai-text-hover); /* Use AI hover text color */
|
||||
background-color: rgba(155, 53, 53, 0.1); /* Subtle reddish background on hover */
|
||||
}
|
||||
.leftColumn > .iconButton:last-child:disabled {
|
||||
color: #c7a9a9; /* Muted red when disabled */
|
||||
background-color: transparent !important;
|
||||
}
|
||||
/* --- End AI Regen Button Style --- */
|
||||
|
||||
.deleteButton:hover {
|
||||
color: #dc3545; /* Red for delete */
|
||||
}
|
||||
|
||||
.starButton {
|
||||
color: #ffc107; /* Yellow for stars */
|
||||
}
|
||||
.starButton:hover {
|
||||
color: #e0a800;
|
||||
}
|
||||
|
||||
|
||||
.summary {
|
||||
color: #555;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px 0; /* Add bottom margin */
|
||||
}
|
||||
|
||||
.dragHandle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* --- New Styles for Status, Keywords, Favicon, Note, Star --- */
|
||||
.statusInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
min-height: 50px; /* Give it some height */
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
color: #dc3545; /* Red for error */
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.keywordsContainer {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.keywordIcon {
|
||||
color: #6c757d;
|
||||
margin-right: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.keywordTag {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px; /* Pill shape */
|
||||
font-size: 0.8em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.moreKeywords {
|
||||
font-size: 0.8em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
/* margin-right: 8px; */ /* Spacing handled by leftColumn gap */
|
||||
}
|
||||
|
||||
.noteContainer {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #eee;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.noteIcon {
|
||||
color: #6c757d;
|
||||
margin-top: 2px; /* Align icon nicely */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.noteText {
|
||||
white-space: pre-wrap; /* Respect line breaks in notes */
|
||||
}
|
||||
|
||||
|
||||
/* --- End New Styles --- */
|
||||
|
||||
92
frontend_react/src/components/UrlCardList/UrlCardList.jsx
Normal file
92
frontend_react/src/components/UrlCardList/UrlCardList.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// frontend/src/components/UrlCardList/UrlCardList.jsx
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import UrlCard from '../UrlCard/UrlCard.jsx';
|
||||
import styles from './UrlCardList.module.css';
|
||||
|
||||
/**
|
||||
* UrlCardList Component
|
||||
* Renders the list and handles drag-and-drop.
|
||||
* Now accepts and passes down onViewDetails prop.
|
||||
*/
|
||||
// Accept onViewDetails prop
|
||||
function UrlCardList({ urls, onOrderChange, onDelete, onRegenerate, onViewDetails }) {
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = urls.findIndex((url) => url.id === active.id);
|
||||
const newIndex = urls.findIndex((url) => url.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
console.error("Could not find dragged item index");
|
||||
return;
|
||||
}
|
||||
const newOrder = arrayMove(urls, oldIndex, newIndex);
|
||||
onOrderChange(newOrder);
|
||||
}
|
||||
};
|
||||
|
||||
if (!urls || urls.length === 0) {
|
||||
return <div className={styles.emptyList}>No URLs added to this project yet. Start by adding one below!</div>;
|
||||
}
|
||||
|
||||
const urlIds = urls.map(url => url.id);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={urlIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={styles.urlCardList}>
|
||||
{urls.map((url) => (
|
||||
<UrlCard
|
||||
key={url.id}
|
||||
id={url.id}
|
||||
url={url.url}
|
||||
title={url.title}
|
||||
summary={url.summary}
|
||||
keywords={url.keywords} // Pass keywords
|
||||
processingStatus={url.processingStatus} // Pass status
|
||||
favicon={url.favicon} // Pass favicon
|
||||
starred={url.starred} // Pass starred
|
||||
note={url.note} // Pass note
|
||||
isLoading={url.isLoading}
|
||||
onDelete={() => onDelete(url.id)}
|
||||
onRegenerate={() => onRegenerate(url.id)}
|
||||
onViewDetails={() => onViewDetails(url.id)} // Pass onViewDetails down
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default UrlCardList;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* components/UrlCardList/UrlCardList.module.css */
|
||||
.urlCardList {
|
||||
/* Container for the list */
|
||||
margin-top: 20px; /* Space below header */
|
||||
flex-grow: 1; /* Allows list to take available space if MainContent is flex */
|
||||
}
|
||||
|
||||
.emptyList {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
padding: 40px 20px;
|
||||
border: 2px dashed #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
308
frontend_react/src/components/UrlDetailPage/UrlDetailPage.jsx
Normal file
308
frontend_react/src/components/UrlDetailPage/UrlDetailPage.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchUrlDetails, updateUrlDetails } from '../../services/api'; // Import API functions
|
||||
import styles from './UrlDetailPage.module.css'; // We'll create this CSS module next
|
||||
import { FaLink, FaStar, FaRegStar, FaStickyNote, FaTags, FaInfoCircle, FaSpinner, FaExclamationTriangle, FaCalendarAlt, FaSave, FaTimes, FaEdit, FaCheckCircle } from 'react-icons/fa'; // Import icons
|
||||
|
||||
// Helper to format date strings
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
// Assuming dateString is ISO 8601 UTC (ends with Z)
|
||||
return new Date(dateString).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString; // Return original if formatting fails
|
||||
}
|
||||
};
|
||||
|
||||
// Simple Keyword Tag component (can be shared or kept local)
|
||||
const KeywordTag = ({ keyword }) => (
|
||||
<span className={styles.keywordTag} title={`${keyword.percentage.toFixed(1)}%`}>
|
||||
{keyword.word}
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* UrlDetailPage Component
|
||||
* Fetches, displays, and allows editing of URL details.
|
||||
* Expects `urlId` prop and an `onBack` function prop to navigate back.
|
||||
*/
|
||||
function UrlDetailPage({ urlId, onBack }) {
|
||||
const [urlData, setUrlData] = useState(null); // Original fetched data
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isSaving, setIsSaving] = useState(false); // State for save operation
|
||||
const [saveSuccess, setSaveSuccess] = useState(false); // State for success message
|
||||
|
||||
// --- State for Editing ---
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
const [editedSummary, setEditedSummary] = useState('');
|
||||
const [editedNote, setEditedNote] = useState('');
|
||||
// --- End Editing State ---
|
||||
|
||||
// Fetch data when urlId changes
|
||||
const loadUrlData = useCallback(() => {
|
||||
if (!urlId) {
|
||||
setError("No URL ID provided.");
|
||||
setIsLoading(false);
|
||||
setUrlData(null);
|
||||
return;
|
||||
}
|
||||
console.log(`UrlDetailPage: Fetching details for URL ID: ${urlId}`);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setUrlData(null);
|
||||
setIsEditing(false);
|
||||
|
||||
fetchUrlDetails(urlId)
|
||||
.then(data => {
|
||||
setUrlData(data);
|
||||
// Initialize edit state when data loads (also ensures reset if data reloads)
|
||||
setEditedTitle(data?.title || '');
|
||||
setEditedSummary(data?.summary || '');
|
||||
setEditedNote(data?.note || '');
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`UrlDetailPage: Failed to fetch URL details for ${urlId}:`, err);
|
||||
setError(err.message || "Failed to load URL details.");
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [urlId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUrlData();
|
||||
}, [loadUrlData]);
|
||||
|
||||
// --- Edit Mode Handlers ---
|
||||
const handleEdit = () => {
|
||||
if (!urlData) return;
|
||||
// Re-initialize edit fields with current data when entering edit mode
|
||||
setEditedTitle(urlData.title || '');
|
||||
setEditedSummary(urlData.summary || '');
|
||||
setEditedNote(urlData.note || '');
|
||||
setIsEditing(true);
|
||||
setSaveSuccess(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setError(null);
|
||||
// No need to reset fields explicitly, they will be re-initialized
|
||||
// from urlData next time edit is clicked.
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!urlData) return;
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
|
||||
const updateData = {
|
||||
title: editedTitle,
|
||||
summary: editedSummary,
|
||||
note: editedNote,
|
||||
};
|
||||
|
||||
try {
|
||||
// Pass only changed data (optional optimization, backend handles it)
|
||||
const changedData = {};
|
||||
if (editedTitle !== urlData.title) changedData.title = editedTitle;
|
||||
if (editedSummary !== urlData.summary) changedData.summary = editedSummary;
|
||||
if (editedNote !== urlData.note) changedData.note = editedNote;
|
||||
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
console.log("No changes detected, exiting edit mode.");
|
||||
setIsEditing(false);
|
||||
setIsSaving(false);
|
||||
return; // No need to call API if nothing changed
|
||||
}
|
||||
|
||||
|
||||
const updatedUrl = await updateUrlDetails(urlId, changedData); // Send only changed data
|
||||
// Update local state with the response from the API OR merge changes
|
||||
// Merging changes locally might be smoother if API doesn't return full object
|
||||
setUrlData(prevData => ({
|
||||
...prevData,
|
||||
...changedData // Apply local changes directly
|
||||
// Alternatively, if API returns full updated object: ...updatedUrl
|
||||
}));
|
||||
setIsEditing(false);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2500);
|
||||
} catch (err) {
|
||||
console.error("UrlDetailPage: Failed to save URL details:", err);
|
||||
setError(err.message || "Failed to save changes.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
// --- End Edit Mode Handlers ---
|
||||
|
||||
// --- Star Toggle Handler ---
|
||||
const handleToggleStar = async () => {
|
||||
if (!urlData || isSaving || isEditing) return;
|
||||
|
||||
const newStarredStatus = !urlData.starred;
|
||||
const originalStatus = urlData.starred;
|
||||
setUrlData(prevData => ({ ...prevData, starred: newStarredStatus }));
|
||||
|
||||
try {
|
||||
await updateUrlDetails(urlId, { starred: newStarredStatus });
|
||||
} catch (err) {
|
||||
console.error("UrlDetailPage: Failed to update star status:", err);
|
||||
setUrlData(prevData => ({ ...prevData, starred: originalStatus }));
|
||||
alert(`Failed to update star status: ${err.message}`);
|
||||
}
|
||||
};
|
||||
// --- End Star Toggle Handler ---
|
||||
|
||||
// --- Render states ---
|
||||
if (isLoading) {
|
||||
return <div className={styles.statusMessage}><FaSpinner className={styles.spinnerIcon} /> Loading URL Details...</div>;
|
||||
}
|
||||
if (error && !isEditing) {
|
||||
return (
|
||||
<div className={`${styles.statusMessage} ${styles.error}`}>
|
||||
<FaExclamationTriangle /> {error}
|
||||
{onBack && <button onClick={onBack} className={styles.backButton}>Go Back</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!urlData && !isLoading) {
|
||||
return <div className={styles.statusMessage}>URL data not available.</div>;
|
||||
}
|
||||
|
||||
// --- Render URL Details ---
|
||||
return (
|
||||
<div className={styles.detailPageContainer}>
|
||||
{onBack && ( // Always show back button?
|
||||
<button onClick={onBack} className={styles.backButton} disabled={isSaving}>
|
||||
← Back to List
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Header: Title, Favicon, Star, Edit/Save/Cancel */}
|
||||
<div className={styles.header}>
|
||||
{urlData.favicon && <img src={urlData.favicon} alt="favicon" className={styles.favicon} onError={(e) => e.target.style.display='none'}/>}
|
||||
|
||||
{/* Editable Title */}
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedTitle}
|
||||
// *** ADDED onChange ***
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
className={`${styles.titleInput} ${styles.inputField}`}
|
||||
disabled={isSaving}
|
||||
aria-label="URL Title"
|
||||
/>
|
||||
) : (
|
||||
<h1 className={styles.title}>{urlData.title || 'No Title'}</h1>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleToggleStar}
|
||||
className={styles.starButton}
|
||||
title={urlData.starred ? "Unstar" : "Star"}
|
||||
disabled={isSaving || isEditing} // Disable while saving/editing other fields
|
||||
>
|
||||
{urlData.starred ? <FaStar /> : <FaRegStar />}
|
||||
</button>
|
||||
|
||||
<div className={styles.editControls}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button onClick={handleSave} className={styles.saveButton} disabled={isSaving}>
|
||||
{isSaving ? <FaSpinner className={styles.spinnerIconSmall}/> : <FaSave />} Save
|
||||
</button>
|
||||
<button onClick={handleCancel} className={styles.cancelButton} disabled={isSaving}>
|
||||
<FaTimes /> Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={handleEdit} className={styles.editButton}>
|
||||
<FaEdit /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Save Error/Success Messages */}
|
||||
{error && isEditing && <p className={`${styles.statusMessage} ${styles.error}`}>{error}</p>}
|
||||
{saveSuccess && <p className={`${styles.statusMessage} ${styles.success}`}><FaCheckCircle/> Saved successfully!</p>}
|
||||
|
||||
|
||||
<a href={urlData.url} target="_blank" rel="noopener noreferrer" className={styles.urlLink}>
|
||||
<FaLink /> {urlData.url}
|
||||
</a>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<div className={styles.metadata}>
|
||||
<span className={styles.metadataItem} title="Processing Status">
|
||||
<FaInfoCircle /> Status: <span className={`${styles.statusBadge} ${styles[urlData.processingStatus]}`}>{urlData.processingStatus}</span>
|
||||
</span>
|
||||
<span className={styles.metadataItem} title="Last Updated">
|
||||
<FaCalendarAlt /> Updated: {formatDate(urlData.updatedAt)}
|
||||
</span>
|
||||
<span className={styles.metadataItem} title="Date Added">
|
||||
<FaCalendarAlt /> Added: {formatDate(urlData.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary Section (Editable) */}
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Summary</h2>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedSummary}
|
||||
// *** ADDED onChange ***
|
||||
onChange={(e) => setEditedSummary(e.target.value)}
|
||||
className={`${styles.summaryTextarea} ${styles.inputField}`}
|
||||
rows={5}
|
||||
disabled={isSaving}
|
||||
aria-label="URL Summary"
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.summaryText}>{urlData.summary || <span className={styles.noNote}>No summary available.</span>}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keywords Section */}
|
||||
{urlData.keywords && urlData.keywords.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}><FaTags /> Keywords</h2>
|
||||
<div className={styles.keywordsContainer}>
|
||||
{urlData.keywords.map((kw, index) => <KeywordTag key={index} keyword={kw} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Section (Editable) */}
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}><FaStickyNote /> Note</h2>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedNote}
|
||||
// *** ADDED onChange ***
|
||||
onChange={(e) => setEditedNote(e.target.value)}
|
||||
className={`${styles.noteTextarea} ${styles.inputField}`}
|
||||
rows={4}
|
||||
disabled={isSaving}
|
||||
aria-label="User Note"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.noteContent}>
|
||||
{urlData.note || <span className={styles.noNote}>No note added yet.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UrlDetailPage;
|
||||
@@ -0,0 +1,293 @@
|
||||
/* frontend/src/components/UrlDetailPage/UrlDetailPage.module.css */
|
||||
|
||||
.detailPageContainer {
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--white-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.statusMessage {
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.statusMessage.error {
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(220, 53, 69, 0.05); /* Light red background */
|
||||
border: 1px solid rgba(220, 53, 69, 0.2);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.statusMessage.success {
|
||||
color: var(--success-color); /* Use variable */
|
||||
background-color: rgba(40, 167, 69, 0.05); /* Light green background */
|
||||
border: 1px solid rgba(40, 167, 69, 0.2);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
|
||||
.spinnerIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.spinnerIconSmall {
|
||||
display: inline-block; /* Allow margin */
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 5px; /* Space after spinner */
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.backButton {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.backButton:hover {
|
||||
background-color: var(--light-color);
|
||||
border-color: #bbb;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
.backButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.favicon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.8em;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-primary);
|
||||
margin: 0;
|
||||
flex-grow: 1; /* Allow title to take space */
|
||||
line-height: 1.2;
|
||||
/* Ensure it doesn't push buttons too far in edit mode */
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.starButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffc107; /* Yellow for stars */
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
font-size: 1.3em; /* Make star slightly larger */
|
||||
transition: var(--transition-fast);
|
||||
line-height: 1;
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
.starButton:hover {
|
||||
color: #e0a800;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.starButton:disabled {
|
||||
color: #ccc; /* Muted color when disabled */
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
.urlLink {
|
||||
display: inline-block;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.urlLink svg {
|
||||
margin-right: var(--spacing-xs);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.urlLink:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-hover-color);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
.metadataItem { display: flex; align-items: center; gap: var(--spacing-xs); }
|
||||
.metadataItem svg { font-size: 1.1em; }
|
||||
.statusBadge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-weight: 500; font-size: 0.9em; text-transform: capitalize; }
|
||||
.statusBadge.completed { background-color: #d4edda; color: #155724; }
|
||||
.statusBadge.pending { background-color: #fff3cd; color: #856404; }
|
||||
.statusBadge.failed { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
.section { margin-bottom: var(--spacing-lg); }
|
||||
.sectionTitle { font-size: 1.1em; font-weight: 600; color: var(--text-color-primary); margin-top: 0; margin-bottom: var(--spacing-sm); display: flex; align-items: center; gap: var(--spacing-sm); }
|
||||
.sectionTitle svg { color: var(--text-color-secondary); }
|
||||
|
||||
.summaryText,
|
||||
.noteContent {
|
||||
font-size: 0.95em;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color-primary);
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--light-color);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 40px; /* Ensure some height even if empty */
|
||||
}
|
||||
.noNote { font-style: italic; color: var(--text-color-secondary); }
|
||||
|
||||
.keywordsContainer { display: flex; flex-wrap: wrap; gap: var(--spacing-sm); }
|
||||
.keywordTag { background-color: #e9ecef; color: #495057; padding: 3px 10px; border-radius: 15px; font-size: 0.85em; white-space: nowrap; cursor: default; transition: var(--transition-fast); }
|
||||
.keywordTag:hover { background-color: #dee2e6; }
|
||||
|
||||
/* --- Edit Mode Styles --- */
|
||||
.editControls {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
|
||||
.inputField { /* Common styles for edit inputs */
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 1em; /* Match display font size */
|
||||
font-family: inherit;
|
||||
background-color: #fff; /* Explicit white background */
|
||||
transition: var(--transition-base);
|
||||
box-sizing: border-box; /* Include padding/border in width */
|
||||
color: var(--text-color-primary); /* <<< FIX: Explicitly set text color */
|
||||
}
|
||||
.inputField:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(178, 227, 182, 0.25); /* Adjusted focus color to match primary */
|
||||
}
|
||||
.inputField:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
color: var(--text-color-secondary); /* Dim text color when disabled */
|
||||
}
|
||||
|
||||
|
||||
.titleInput {
|
||||
font-size: 1.8em; /* Match h1 */
|
||||
font-weight: 600; /* Match h1 */
|
||||
line-height: 1.2; /* Match h1 */
|
||||
flex-grow: 1; /* Allow input to grow */
|
||||
min-width: 100px; /* Prevent becoming too small */
|
||||
/* Inherits .inputField styles including color */
|
||||
}
|
||||
|
||||
.summaryTextarea,
|
||||
.noteTextarea {
|
||||
font-size: 0.95em; /* Match p */
|
||||
line-height: 1.6; /* Match p */
|
||||
resize: vertical; /* Allow vertical resize */
|
||||
min-height: 80px;
|
||||
/* Inherits .inputField styles including color */
|
||||
}
|
||||
|
||||
/* Edit/Save/Cancel Button Styles */
|
||||
.editButton, .saveButton, .cancelButton {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
.editButton svg, .saveButton svg, .cancelButton svg {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--text-color-light);
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
.editButton:hover {
|
||||
background-color: var(--secondary-hover-color);
|
||||
border-color: var(--secondary-hover-color);
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
background-color: var(--success-color);
|
||||
color: #fff;
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
.saveButton:hover {
|
||||
background-color: #58a85c; /* Darker success */
|
||||
border-color: #58a85c;
|
||||
}
|
||||
.saveButton:disabled {
|
||||
background-color: #a3d9a5;
|
||||
border-color: #a3d9a5;
|
||||
cursor: not-allowed;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
|
||||
.cancelButton {
|
||||
background-color: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
.cancelButton:hover {
|
||||
background-color: var(--light-color);
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
.cancelButton:disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* --- End Edit Mode Styles --- */
|
||||
|
||||
1
frontend_react/src/components/react.svg
Normal file
1
frontend_react/src/components/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
82
frontend_react/src/index.css
Normal file
82
frontend_react/src/index.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* ./index.css */
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0; /* Keep margin reset */
|
||||
/* display: flex; */ /* REMOVED or COMMENTED OUT */
|
||||
/* place-items: center; */ /* REMOVED or COMMENTED OUT */
|
||||
min-width: 320px; /* Keep min-width if needed */
|
||||
min-height: 100vh; /* Keep min-height */
|
||||
/* You might want body to have the background color instead of :root for dark mode */
|
||||
/* background-color: #242424; */ /* Moved from :root potentially */
|
||||
}
|
||||
|
||||
/* Optional: Ensure #root takes full height */
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
/* display: flex; */ /* Ensure #root doesn't interfere either, usually not needed */
|
||||
/* flex-direction: column; */
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
/* Optionally set light mode body background here if moved from :root */
|
||||
/* body { background-color: #ffffff; } */
|
||||
}
|
||||
10
frontend_react/src/main.jsx
Normal file
10
frontend_react/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx' // <--- 注意这里!
|
||||
import './index.css' // 或者其他全局 CSS 文件
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
0
frontend_react/src/services/.Rhistory
Normal file
0
frontend_react/src/services/.Rhistory
Normal file
603
frontend_react/src/services/api.js
Normal file
603
frontend_react/src/services/api.js
Normal file
@@ -0,0 +1,603 @@
|
||||
// frontend/src/services/api.js
|
||||
|
||||
// Base URL for your Flask API - IMPORTANT: Adjust if your backend runs elsewhere
|
||||
// 后端 Flask API 的基础 URL - 重要:如果后端运行在别处,请调整
|
||||
const API_BASE_URL = 'http://localhost:5000'; // Assuming Flask runs on port 5000
|
||||
|
||||
// --- Helper function to get Auth Token ---
|
||||
// --- 获取认证 Token 的辅助函数 ---
|
||||
export const getAuthToken = () => {
|
||||
return localStorage.getItem('authToken');
|
||||
};
|
||||
|
||||
// --- Helper function to handle 401 Unauthorized ---
|
||||
// --- 处理 401 未授权错误的辅助函数 ---
|
||||
const handleUnauthorized = () => {
|
||||
console.warn("API: Received 401 Unauthorized. Logging out and reloading.");
|
||||
// 清除无效的 token
|
||||
localStorage.removeItem('authToken');
|
||||
// 重新加载页面以强制重新登录
|
||||
window.location.reload();
|
||||
// 抛出错误以停止当前的 Promise 链
|
||||
throw new Error("Authentication failed. Please log in again.");
|
||||
};
|
||||
|
||||
// --- Login Function ---
|
||||
// --- 登录函数 ---
|
||||
export const loginUser = async (username, password) => {
|
||||
console.log('API: Attempting login with username...');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/login`, { // Endpoint from auth.py
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
if (data.token) {
|
||||
console.log('API: Login successful, token received.');
|
||||
return data; // Contains { message, token, user_id }
|
||||
} else {
|
||||
throw new Error('Login successful but no token received from server.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("API Error during login:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Project API Functions ---
|
||||
|
||||
/**
|
||||
* Fetches the list of projects (summary view).
|
||||
* 获取项目列表(摘要视图)。
|
||||
* Corresponds to GET /api/projects
|
||||
*/
|
||||
export const fetchProjects = async () => {
|
||||
console.log('API: Fetching projects...');
|
||||
const token = getAuthToken();
|
||||
// 如果在发送请求前就没有 token,立即触发登出
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// *** 特别检查 401 状态码 ***
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized(); // 触发登出和重新加载
|
||||
}
|
||||
// 对于其他错误,正常抛出
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response' }));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// 后端返回 { projects: [...] } -> 映射 project_id 到 id
|
||||
return (data.projects || []).map(p => ({ ...p, id: p.project_id || p.id || p._id })); // 处理不同的 ID 键可能性
|
||||
} catch (error) {
|
||||
// 避免记录由 handleUnauthorized 生成的 "Authentication failed" 错误
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error("API Error fetching projects:", error);
|
||||
}
|
||||
throw error; // 重新抛出错误,以便组件级别可以处理(如果需要)
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches detailed information for a specific project.
|
||||
* 获取特定项目的详细信息。
|
||||
* Corresponds to GET /api/projects/<project_id>
|
||||
*/
|
||||
export const fetchProjectDetails = async (projectId) => {
|
||||
console.log(`API: Fetching details for project ${projectId}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// *** 特别检查 401 状态码 ***
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
if (response.status === 404) throw new Error(`Project with ID ${projectId} not found.`);
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${errorData.message || 'Failed to fetch details'}`);
|
||||
}
|
||||
const details = await response.json();
|
||||
// 确保 'id' 字段存在,如果需要,从 '_id' 映射
|
||||
if (details._id && !details.id) {
|
||||
details.id = String(details._id); // Ensure it's a string
|
||||
} else if (details.id) {
|
||||
details.id = String(details.id); // Ensure it's a string
|
||||
}
|
||||
return details;
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error fetching details for project ${projectId}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches URLs for a specific project.
|
||||
* 获取特定项目的 URL 列表。
|
||||
* Corresponds to GET /api/projects/<project_id>/urls
|
||||
*/
|
||||
export const fetchProjectUrls = async (projectId) => {
|
||||
console.log(`API: Fetching URLs for project ${projectId}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
// *** 确认后端路由是 /api/projects/<project_id>/urls ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/urls`, {
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
// *** 特别检查 401 状态码 ***
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${errorData.message || 'Failed to fetch URLs'}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// 后端返回 { urls: [...] } -> 映射 _id 到 id
|
||||
return (data.urls || []).map(url => ({ ...url, id: String(url._id || url.id) })); // Ensure id is string
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error fetching URLs for project ${projectId}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Functions below also need the 401 check ---
|
||||
// --- 下面的函数同样需要 401 检查 ---
|
||||
|
||||
/**
|
||||
* Adds a URL to a specific project.
|
||||
* 添加 URL 到特定项目。
|
||||
*/
|
||||
export const addUrlToProject = async (projectId, url) => {
|
||||
console.log(`API: Adding URL ${url} to project ${projectId}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
// *** 确认后端路由是 /api/projects/<project_id>/urls ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/urls`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url }), // 根据后端要求发送 url
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${errorData.message || 'Unknown error adding URL'}`);
|
||||
}
|
||||
const addedUrlData = await response.json();
|
||||
// 映射 url_id (来自后端) 到 id
|
||||
if (addedUrlData.url_id) addedUrlData.id = String(addedUrlData.url_id);
|
||||
return addedUrlData; // 应包含 { message, url_id }
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error("API Error adding URL:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchUrlDetails = async (urlId) => {
|
||||
console.log(`API: Fetching details for URL ${urlId}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized(); // Handle missing token immediately
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/urls/${urlId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized(); // Handle expired/invalid token
|
||||
}
|
||||
// Handle URL not found specifically
|
||||
if (response.status === 404) {
|
||||
throw new Error(`URL with ID ${urlId} not found.`);
|
||||
}
|
||||
// Handle other errors
|
||||
const errorData = await response.json().catch(() => ({})); // Try to get error message
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const urlDetails = await response.json();
|
||||
// Ensure 'id' field exists, map from '_id' if necessary
|
||||
if (urlDetails._id && !urlDetails.id) {
|
||||
urlDetails.id = String(urlDetails._id);
|
||||
} else if (urlDetails.id) {
|
||||
urlDetails.id = String(urlDetails.id);
|
||||
}
|
||||
console.log("API: Fetched URL details:", urlDetails);
|
||||
return urlDetails; // Return the detailed data
|
||||
|
||||
} catch (error) {
|
||||
// Avoid logging the specific "Authentication failed" error again
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error fetching details for URL ${urlId}:`, error);
|
||||
}
|
||||
throw error; // Re-throw the error for the component to handle
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a URL from a project.
|
||||
* 从项目中删除 URL。
|
||||
* NOTE: Backend route seems to be /api/urls/<url_id>. Double-check routes/urls.py
|
||||
* 注意:后端路由似乎是 /api/urls/<url_id>。请检查 routes/urls.py
|
||||
*/
|
||||
export const deleteUrlFromProject = async (urlIdToDelete) => {
|
||||
console.log(`API: Deleting URL ${urlIdToDelete}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
// *** 根据 routes/urls.py 调整端点 ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/urls/${urlIdToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
// 检查 200 OK 或 204 No Content 表示成功删除
|
||||
if (!response.ok && response.status !== 204) {
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${errorData.message || 'Delete URL failed'}`);
|
||||
}
|
||||
console.log(`Deletion successful for ${urlIdToDelete}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error deleting URL ${urlIdToDelete}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers regeneration/reprocessing of a URL (e.g., summary).
|
||||
* 触发 URL 的重新生成/处理(例如摘要)。
|
||||
* NOTE: Backend routes needed for this (e.g., /api/urls/<url_id>/summarize)
|
||||
* 注意:需要后端路由(例如 /api/urls/<url_id>/summarize)
|
||||
*/
|
||||
export const regenerateSummary = async (urlIdToRegen) => {
|
||||
console.log(`API: Regenerating summary for URL ${urlIdToRegen}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
// *** 使用正确的后端端点 (例如 /summarize 或 /extract_title_and_keywords) ***
|
||||
// 检查 backend/routes/urls.py 中的 @urls_bp.route('/urls/<url_id>/summarize', methods=['PUT'])
|
||||
const response = await fetch(`${API_BASE_URL}/api/urls/${urlIdToRegen}/summarize`, {
|
||||
method: 'PUT', // 或者 POST,取决于后端
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({}) // 如果需要,可选的 body
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
// 如果后端将任务排队,处理 202 Accepted
|
||||
if (response.status === 202) {
|
||||
console.log(`API: Regeneration task queued for ${urlIdToRegen}`);
|
||||
// 返回一些东西来指示排队,也许是原始 URL 数据?
|
||||
// 或者只是一个成功消息。取决于前端如何处理排队。
|
||||
return { message: "Regeneration task queued.", status: 'pending' };
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${errorData.message || 'Regeneration failed'}`);
|
||||
}
|
||||
// 如果后端同步处理并返回更新后的数据:
|
||||
const updatedUrlData = await response.json();
|
||||
if (updatedUrlData._id) updatedUrlData.id = String(updatedUrlData._id); // Ensure id is string
|
||||
return updatedUrlData; // 返回更新后的数据
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error regenerating summary for ${urlIdToRegen}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Project Modification API Functions (Add 401 checks similarly) ---
|
||||
// --- 项目修改 API 函数(类似地添加 401 检查)---
|
||||
|
||||
export const createProject = async (projectData) => {
|
||||
console.log('API: Creating new project...', projectData);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(projectData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json(); // Returns { message, project_id, passkey }
|
||||
// Map project_id to id for consistency
|
||||
if (result.project_id) result.id = String(result.project_id);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error("API Error creating project:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateProject = async (projectId, updateData) => {
|
||||
console.log(`API: Updating project ${projectId}...`, updateData);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 403) throw new Error("Permission denied to update project.");
|
||||
if (response.status === 404) throw new Error("Project not found for update.");
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json(); // Returns { message, project? }
|
||||
// Ensure returned project also has 'id'
|
||||
if (result.project && result.project._id && !result.project.id) {
|
||||
result.project.id = String(result.project._id);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error updating project ${projectId}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId) => {
|
||||
console.log(`API: Deleting project ${projectId}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
// Handle 204 No Content specifically
|
||||
if (response.status === 204) return { message: "Project deleted successfully." };
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 403) throw new Error("Permission denied to delete project.");
|
||||
if (response.status === 404) throw new Error("Project not found for deletion.");
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json(); // Returns { message } if backend sends 200
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error deleting project ${projectId}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const recalculateProjectKeywords = async (projectId) => {
|
||||
console.log(`API: Recalculating keywords for project ${projectId}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized();
|
||||
}
|
||||
try {
|
||||
// *** 确认后端路由是 /api/projects/<project_id>/recalc_keywords ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/recalc_keywords`, {
|
||||
method: 'PUT', // 或者 POST,取决于后端
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({}) // 如果需要,可选的 body
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 403) throw new Error("Permission denied.");
|
||||
if (response.status === 404) throw new Error("Project not found.");
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json(); // Returns { message, keywords }
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error recalculating keywords for ${projectId}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- API Key Management Functions (Add 401 checks similarly) ---
|
||||
// --- API 密钥管理函数(类似地添加 401 检查)---
|
||||
export const addApiKey = async (provider, key) => {
|
||||
console.log(`API: Storing API key for provider ${provider}...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) { handleUnauthorized(); }
|
||||
try {
|
||||
// *** 确认后端路由是 /api/api_list ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/api_list`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: provider, key: key, selected: true }), // Example body
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error("API Error adding API key:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getApiKeys = async () => {
|
||||
console.log('API: Fetching stored API keys...');
|
||||
const token = getAuthToken();
|
||||
if (!token) { handleUnauthorized(); }
|
||||
try {
|
||||
// *** 确认后端路由是 /api/api_list ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/api_list`, {
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// Map _id to id
|
||||
return (data.api_keys || []).map(key => ({ ...key, id: String(key._id || key.id) }));
|
||||
} catch (error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error("API Error fetching API keys:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Global AI Function (Add 401 check similarly) ---
|
||||
// --- 全局 AI 函数(类似地添加 401 检查)---
|
||||
export const askAiAboutProject = async (projectId, context) => {
|
||||
console.log(`API: Asking AI about project ${projectId} with context...`);
|
||||
const token = getAuthToken();
|
||||
if (!token) { handleUnauthorized(); }
|
||||
try {
|
||||
// *** 确认或创建后端路由用于 AI 查询, e.g., /api/projects/<project_id>/ask ***
|
||||
const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/ask`, { // Example endpoint
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ context: context }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { handleUnauthorized(); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json(); // Expects { answer: "..." } or similar
|
||||
} catch(error) {
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error("API Error asking AI:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const updateUrlDetails = async (urlId, updateData) => {
|
||||
console.log(`API: Updating URL ${urlId} with data:`, updateData);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
handleUnauthorized(); // Handle missing token immediately
|
||||
}
|
||||
|
||||
// Ensure updateData is an object
|
||||
if (typeof updateData !== 'object' || updateData === null) {
|
||||
throw new Error("Invalid update data provided.");
|
||||
}
|
||||
// Optional: Filter out fields that shouldn't be sent? Backend already does this.
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/urls/${urlId}`, {
|
||||
method: 'PUT', // Use PUT as defined in backend/routes/urls.py
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updateData), // Send only the fields to update
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized(); // Handle expired/invalid token
|
||||
}
|
||||
// Handle URL not found specifically
|
||||
if (response.status === 404) {
|
||||
throw new Error(`URL with ID ${urlId} not found for update.`);
|
||||
}
|
||||
// Handle validation errors from backend (e.g., bad data format)
|
||||
if (response.status === 400) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `Invalid data provided for update.`);
|
||||
}
|
||||
// Handle other errors
|
||||
const errorData = await response.json().catch(() => ({})); // Try to get error message
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Backend returns { message, url? } - return the updated url data if available
|
||||
const result = await response.json();
|
||||
console.log("API: Update URL response:", result);
|
||||
// Ensure 'id' field exists in the returned URL data
|
||||
if (result.url && result.url._id && !result.url.id) {
|
||||
result.url.id = String(result.url._id);
|
||||
} else if (result.url && result.url.id) {
|
||||
result.url.id = String(result.url.id);
|
||||
}
|
||||
return result.url || { success: true }; // Return updated url or success indicator
|
||||
|
||||
} catch (error) {
|
||||
// Avoid logging the specific "Authentication failed" error again
|
||||
if (error.message !== "Authentication failed. Please log in again.") {
|
||||
console.error(`API Error updating details for URL ${urlId}:`, error);
|
||||
}
|
||||
throw error; // Re-throw the error for the component to handle
|
||||
}
|
||||
};
|
||||
372
frontend_react/src/services/api_test.js
Normal file
372
frontend_react/src/services/api_test.js
Normal file
@@ -0,0 +1,372 @@
|
||||
// services/api.js
|
||||
|
||||
// Base URL for your Flask API - IMPORTANT: Adjust if your backend runs elsewhere
|
||||
const API_BASE_URL = 'http://localhost:5000'; // Assuming Flask runs on port 5000
|
||||
|
||||
// --- Helper function to get Auth Token (Still needed for actual login/logout) ---
|
||||
export const getAuthToken = () => {
|
||||
// Example: Retrieve token stored after login
|
||||
return localStorage.getItem('authToken');
|
||||
};
|
||||
|
||||
|
||||
// --- Login Function ---
|
||||
export const loginUser = async (username, password) => {
|
||||
console.log('API: Attempting login with username...');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
if (data.token) {
|
||||
console.log('API: Login successful, token received.');
|
||||
return data;
|
||||
} else {
|
||||
throw new Error('Login successful but no token received from server.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("API Error during login:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Placeholder Data (Updated Structures reflecting Schemas) ---
|
||||
const MOCK_PROJECTS = [
|
||||
{ id: 'project1', name: 'Project Alpha' },
|
||||
{ id: 'project2', name: 'Project Beta Research' },
|
||||
{ id: 'project3', name: 'Competitor Analysis' },
|
||||
];
|
||||
|
||||
const MOCK_PROJECT_DETAILS = {
|
||||
project1: {
|
||||
id: 'project1', // Corresponds to _id from backend
|
||||
name: 'Project Alpha',
|
||||
description: 'Initial research for Project Alpha focusing on market trends and existing solutions.',
|
||||
// Changed 'wordCloudData' to 'keywords' matching schema
|
||||
keywords: [ { word: 'market', percentage: 85.5 }, { word: 'research', percentage: 72.1 }, { word: 'trends', percentage: 60.0 }, { word: 'solutions', percentage: 55.9 }, { word: 'AI', percentage: 50.2 }, { word: 'data', percentage: 45.0 } ],
|
||||
topic: 'Market Research', // Optional field from schema
|
||||
summary: 'AI-generated summary about market trends for Alpha.', // Optional field from schema
|
||||
// Other fields like ownerId, collaborators, createdAt etc. could exist
|
||||
},
|
||||
project2: {
|
||||
id: 'project2',
|
||||
name: 'Project Beta Research',
|
||||
description: 'Deep dive into technical specifications for Project Beta.',
|
||||
keywords: [ { word: 'technical', percentage: 90.0 }, { word: 'specs', percentage: 88.2 }, { word: 'beta', percentage: 75.0 }, { word: 'details', percentage: 70.1 } ],
|
||||
topic: 'Technical Specification',
|
||||
summary: 'Summary of Project Beta technical details.'
|
||||
},
|
||||
project3: {
|
||||
id: 'project3',
|
||||
name: 'Competitor Analysis',
|
||||
description: 'Analyzing key competitors in the field.',
|
||||
keywords: [ { word: 'competitor', percentage: 92.3 }, { word: 'analysis', percentage: 89.9 }, { word: 'features', percentage: 81.5 }, { word: 'pricing', percentage: 78.0 } ],
|
||||
topic: 'Business Strategy',
|
||||
summary: 'Analysis summary of main competitors.'
|
||||
}
|
||||
};
|
||||
|
||||
const MOCK_URLS = {
|
||||
project1: [
|
||||
{
|
||||
id: 'url1', // Corresponds to _id
|
||||
projectId: 'project1',
|
||||
url: 'https://example.com/market-trends',
|
||||
title: 'Market Trends Report 2025',
|
||||
summary: 'An overview of current market trends and future predictions.',
|
||||
// Changed 'topic' to 'keywords' matching schema
|
||||
keywords: [{ word: 'trends', percentage: 90.1 }, { word: 'market', percentage: 88.5 }, { word: 'forecast', percentage: 75.3 }],
|
||||
processingStatus: 'completed', // Added processingStatus
|
||||
favicon: 'https://www.google.com/s2/favicons?sz=16&domain_url=example.com', // Example favicon fetch URL
|
||||
starred: false, // Optional field
|
||||
note: '', // Optional field
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
// isLoading: false // Frontend state, not part of backend data model
|
||||
},
|
||||
{
|
||||
id: 'url2',
|
||||
projectId: 'project1',
|
||||
url: 'https://example.com/ai-solutions',
|
||||
title: 'AI Solutions Overview',
|
||||
summary: 'Exploring various AI solutions applicable to the industry problems.',
|
||||
keywords: [{ word: 'AI', percentage: 95.0 }, { word: 'solutions', percentage: 85.0 }, { word: 'ML', percentage: 80.0 }],
|
||||
processingStatus: 'completed',
|
||||
favicon: null,
|
||||
starred: true,
|
||||
note: 'Check this one for implementation details.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'url_pending',
|
||||
projectId: 'project1',
|
||||
url: 'https://example.com/newly-added',
|
||||
title: 'Newly Added Page (Processing...)',
|
||||
summary: null, // Summary not yet available
|
||||
keywords: [], // Keywords not yet available
|
||||
processingStatus: 'pending', // Status is pending
|
||||
favicon: null,
|
||||
starred: false,
|
||||
note: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'url_failed',
|
||||
projectId: 'project1',
|
||||
url: 'https://example.com/failed-page',
|
||||
title: 'Failed Page Processing',
|
||||
summary: null,
|
||||
keywords: [],
|
||||
processingStatus: 'failed', // Status is failed
|
||||
favicon: null,
|
||||
starred: false,
|
||||
note: 'Processing failed, maybe retry?',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
project2: [
|
||||
{
|
||||
id: 'url3',
|
||||
projectId: 'project2',
|
||||
url: 'https://example.com/tech-specs-beta',
|
||||
title: 'Project Beta Tech Specs',
|
||||
summary: 'Detailed technical specifications document.',
|
||||
keywords: [{word: 'specification', percentage: 98.0}, {word: 'hardware', percentage: 85.0}],
|
||||
processingStatus: 'completed',
|
||||
favicon: null,
|
||||
starred: false,
|
||||
note: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
project3: [], // Start with no URLs
|
||||
};
|
||||
|
||||
const MOCK_API_KEYS = [
|
||||
{ _id: 'key1', name: 'Gemini', updatedAt: '2025-04-11T...', createdAt: '...', selected: true /*, key: '****' */ }, // Key masked/omitted
|
||||
{ _id: 'key2', name: 'OpenAI', updatedAt: '2025-04-10T...', createdAt: '...', selected: false /*, key: '****' */ },
|
||||
];
|
||||
|
||||
|
||||
// --- API Functions (Temporarily bypassing token checks for debugging) ---
|
||||
|
||||
/**
|
||||
* Fetches the list of projects.
|
||||
*/
|
||||
export const fetchProjects = async () => {
|
||||
console.log('API: Fetching projects...');
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
|
||||
try {
|
||||
// Replace with actual fetch call later
|
||||
// const response = await fetch(`${API_BASE_URL}/api/projects`, { headers: { 'Authorization': `Bearer ${token}`, ... }});
|
||||
// ... fetch logic ...
|
||||
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
console.log('API: Returning MOCK_PROJECTS');
|
||||
return MOCK_PROJECTS;
|
||||
|
||||
} catch (error) {
|
||||
console.error("API Error fetching projects:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches details for a specific project.
|
||||
*/
|
||||
export const fetchProjectDetails = async (projectId) => {
|
||||
console.log(`API: Fetching details for project ${projectId}...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated")); // <--- This was causing the error
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
|
||||
try {
|
||||
// Replace with actual fetch call later
|
||||
// const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}`, { headers: { 'Authorization': `Bearer ${token}`, ... }});
|
||||
// ... fetch logic ...
|
||||
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const details = MOCK_PROJECT_DETAILS[projectId];
|
||||
if (!details) throw new Error(`Project with ID ${projectId} not found.`);
|
||||
console.log(`API: Returning MOCK_PROJECT_DETAILS for ${projectId}`);
|
||||
return details;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`API Error fetching details for project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches URLs for a specific project.
|
||||
*/
|
||||
export const fetchProjectUrls = async (projectId) => {
|
||||
console.log(`API: Fetching URLs for project ${projectId}...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
|
||||
try {
|
||||
// Replace with actual fetch call later
|
||||
// const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/urls`, { headers: { 'Authorization': `Bearer ${token}`, ... }});
|
||||
// ... fetch logic ...
|
||||
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
console.log(`API: Returning MOCK_URLS for ${projectId}`);
|
||||
return MOCK_URLS[projectId] || [];
|
||||
|
||||
} catch (error) {
|
||||
console.error(`API Error fetching URLs for project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Adds a URL to a specific project.
|
||||
* NOTE: This might still fail if backend requires auth, even if frontend bypasses check here.
|
||||
* For mock testing, ensure it returns mock data correctly.
|
||||
*/
|
||||
export const addUrlToProject = async (projectId, url) => {
|
||||
console.log(`API: Adding URL ${url} to project ${projectId}...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
|
||||
try {
|
||||
// Replace with actual fetch call later
|
||||
// const response = await fetch(`${API_BASE_URL}/api/projects/${projectId}/urls`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, ... }, body: ... });
|
||||
// ... fetch logic ...
|
||||
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (url.includes('fail')) throw new Error("Mock Error: Failed to process URL");
|
||||
const newId = `url${Date.now()}`;
|
||||
const newCardData = { /* ... create mock data ... */ };
|
||||
// ... add to mock list ...
|
||||
// ... simulate backend processing ...
|
||||
console.log(`API: Returning mock added URL ${newId}`);
|
||||
return newCardData; // Return initial pending state
|
||||
|
||||
} catch (error) {
|
||||
console.error("API Error adding URL:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Functions for API Key Management (Assume these are called from a settings page, might need auth bypass too if testing that page) ---
|
||||
|
||||
export const addApiKey = async (provider, key) => {
|
||||
console.log(`API: Storing API key for provider ${provider}...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
// ... (rest of the function using fetch or mocks) ...
|
||||
try {
|
||||
// Simulate success for now
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
console.log("API: Mock API Key stored successfully.");
|
||||
return { message: "API key stored successfully." };
|
||||
} catch (error) {
|
||||
console.error("API Error adding/updating API key:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getApiKeys = async () => {
|
||||
console.log('API: Fetching stored API keys...');
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
try {
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
console.log("API: Returning MOCK_API_KEYS");
|
||||
return MOCK_API_KEYS.map(k => ({ id: k._id, name: k.name, selected: k.selected, updatedAt: k.updatedAt }));
|
||||
} catch (error) {
|
||||
console.error("API Error fetching API keys:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Other API functions (Add similar temporary bypass if needed for debugging them) ---
|
||||
|
||||
export const askAiAboutProject = async (projectId, context) => {
|
||||
console.log(`API: Asking AI about project ${projectId} with context...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
try {
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
console.log("API: Returning Mock AI Response");
|
||||
return {
|
||||
answer: `Mock AI Response: Based on context for project ${projectId}, the key themes are X, Y, and Z.`
|
||||
};
|
||||
} catch(error) {
|
||||
console.error("API Error asking AI:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUrlFromProject = async (projectId, urlId) => {
|
||||
console.log(`API: Deleting URL ${urlId} from project ${projectId}...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
try {
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
if (MOCK_URLS[projectId]) {
|
||||
MOCK_URLS[projectId] = MOCK_URLS[projectId].filter(url => url.id !== urlId);
|
||||
}
|
||||
console.log(`Mock deletion of ${urlId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`API Error deleting URL ${urlId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const regenerateSummary = async (projectId, urlId) => {
|
||||
console.log(`API: Regenerating summary for URL ${urlId} in project ${projectId}...`);
|
||||
// const token = getAuthToken();
|
||||
// // --- TEMPORARILY COMMENTED OUT FOR DEBUGGING ---
|
||||
// if (!token) return Promise.reject(new Error("Not authenticated"));
|
||||
// --- END TEMPORARY COMMENT ---
|
||||
try {
|
||||
// --- Using Mock Data ---
|
||||
await new Promise(resolve => setTimeout(resolve, 1800));
|
||||
let updatedUrlData = null;
|
||||
// ... (logic to update mock data) ...
|
||||
if (!updatedUrlData) throw new Error("Mock Error: URL not found for regeneration");
|
||||
console.log(`API: Returning mock regenerated data for ${urlId}`);
|
||||
return updatedUrlData;
|
||||
} catch (error) {
|
||||
console.error(`API Error regenerating summary for ${urlId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
7
frontend_react/vite.config.js
Normal file
7
frontend_react/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user