Initial Commit

This commit is contained in:
ldy
2025-06-09 17:53:19 +08:00
parent de861d4815
commit 2be3d00ac4
96 changed files with 13327 additions and 2 deletions

0
frontend_react/.Rhistory Normal file
View File

24
frontend_react/.gitignore vendored Normal file
View 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
View 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.

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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;

View 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 --- */

View 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;

View 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 */
}

View 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;

View 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 */
}

View 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;

View 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;
}

View File

@@ -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;

View File

@@ -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); */
}
}

View 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;

View 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 --- */

View 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;

View File

@@ -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;
}

View 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}>
&larr; 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;

View File

@@ -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 --- */

View 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

View 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; } */
}

View 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>,
)

View File

View 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
}
};

View 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;
}
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})